Skip to content

Latest commit

 

History

History
301 lines (227 loc) · 12.8 KB

File metadata and controls

301 lines (227 loc) · 12.8 KB

Getting started

Build something small and complete: a page that reads data from a Go API, a form that writes back, and one interactive island. Follow it top to bottom and you will have touched every convention borgo has. It takes about twenty minutes, and you need Bun 1.4+ and Go 1.27+.

Scaffold

bunx create-borgo@latest notes

In a terminal it asks seven questions: template (pick minimal, the one this guide builds on), Tailwind (say no; styling covers it later), a linter (none is fine here), then git init, the Docker files and the VS Code settings — those three default to yes, so pressing enter is the right answer. The last question, "install dependencies and start the dev server", also defaults to yes in a terminal: say yes and the scaffolder runs the four commands below for you and hands over to the dev server. Say no and it prints them as next steps instead:

cd notes
bun install
go mod tidy
bun run dev

Every question has a flag, and --yes takes every default without asking: bunx create-borgo@latest notes -t minimal --yes produces exactly what this guide assumes — and, in a terminal, installs and starts too. Outside a terminal (CI, piped stdin) the install-and-start default flips to no, so a scaffold in a pipeline exits on its own.

Either way, open http://localhost:3000. You should see the borgo logo and a line of text that came from Go.

What you just got

A small tree, and no file in it is there by accident. The nine that make the app:

pages/index.tsx       your first page - the file name is the route
api/hello.go          your first API route
main.go               the Go entrypoint: imports api, calls borgo.Serve()
.borgo/               generated types for your Go routes (api-types.d.ts, the one
                      file under .borgo/ you commit; everything else here is
                      gitignored and rebuilt on every dev run and build)
index.html            the HTML shell every page renders into
style.scss            global styles
package.json          bun scripts: dev, build, start, export, doctor
go.mod                the Go module, with borgogen wired as a tool
tsconfig.json         includes .borgo/api-types.d.ts explicitly (dot-dirs are skipped otherwise)

And the rest, which the scaffolder's answers control rather than the template:

README.md             the template's own readme, trimmed to the answers you gave
public/logo.svg       served as-is from /logo.svg - public/ is static files, and the
                      only place for a file the browser fetches (see "Ship it")
Dockerfile            multi-stage build, small bun runtime      \  --no-docker
docker-compose.yml    the one-container deployment              |  drops
.dockerignore         what the image build does not need        /  these three
.gitignore            node_modules, .env, dist, build output
.vscode/              extensions.json and settings.json         -  --no-vscode drops it

--tailwind would have left a style.css here instead of style.scss, and a --linter choice would add its config file plus lint and format scripts. Neither is in this walkthrough.

Two processes are now running: a Bun server on :3000 that renders your React pages, and a Go server on :3501 that owns /api/*. The Bun server proxies to it, so from the browser there is one origin and one port.

Add a page

Create pages/about.tsx:

export const head = { title: "About" };

export default function About() {
  return (
    <main>
      <h1>About</h1>
      <p>A notes app, built with borgo.</p>
      <a href="/">Back home</a>
    </main>
  );
}

Visit http://localhost:3000/about — it is already there. The file name is the route: pages/about.tsx/about, pages/notes/[id].tsx/notes/:id.

Now click the "Back home" link and watch the network tab: no full page load. borgo turns plain <a> tags into client-side navigations, fetching only the route's JavaScript chunk and its data. There is no <Link> component to import.

Add an API route

Open api/hello.go and add a second handler below the first:

type Note struct {
	ID    int    `json:"id"`
	Title string `json:"title"`
}

type NoteList struct {
	Notes []Note `json:"notes"`
}

var notes = []Note{{ID: 1, Title: "Buy oranges"}}

//borgo:route GET /api/notes
func ListNotes(w http.ResponseWriter, r *http.Request) {
	borgo.JSON(w, http.StatusOK, NoteList{Notes: notes})
}

The //borgo:route comment is the whole registration: no router file to update, no import to add. Save it, and watch the terminal — Go rebuilds, and something else happens too. Look at .borgo/api-types.d.ts:

export interface Note {
  id: number;
  title: string;
}

export interface NoteList {
  notes: Array<Note> | null;
}

declare module "borgo-framework" {
  interface ApiRoutes {
    "GET /api/notes": { response: NoteList };
  }
}

That file is generated by reading your Go source — not by running it, not from a spec you maintain. Your Go structs are now TypeScript types, and the route exists in the type system.

Read the data on the server

Replace pages/index.tsx with:

import type { LoaderContext } from "borgo-framework";
import type { Note } from "@/.borgo/api-types";

export const head = { title: "Notes" };

export async function loader({ api }: LoaderContext) {
  const { notes } = await api("GET /api/notes");
  // a nil Go slice is null on the wire, so notes is Array<Note> | null
  return { notes: notes ?? [] };
}

export default function Home({ notes }: { notes: Note[] }) {
  return (
    <main>
      <h1>Notes</h1>
      <ul>
        {notes.map((note) => (
          <li key={note.id}>{note.title}</li>
        ))}
      </ul>
      <a href="/about">About</a>
    </main>
  );
}

Reload. The note is there — and it was in the HTML before any JavaScript ran: view source and you will find <li>Buy oranges</li> in the document. The loader runs on the server before rendering, and whatever it returns becomes the component's props.

The ?? [] is not defensive padding. A nil Go slice marshals to null, not [], so NoteList.notes is generated as Array<Note> | null and TypeScript makes you say what an empty list looks like before you map over it — see the nil slice trap. Normalizing in the loader is the tidiest place: the component below never has to think about it.

Now try to break it on purpose. Change the route string to "GET /api/note":

error TS2345: Argument of type '"GET /api/note"' is not assignable to parameter of type 'Registered'.

Registered is the union of every route borgogen wrote into ApiRoutes; a second error on the same line, Property 'notes' does not exist on type 'Greeting | NoteList', is the response type falling back to the union of all of them. The typo is a compile error, not a 404 you find in production. Change it back, then try notes.map((note) => note.tilte) — same story. This is the point of the bridge: the Go handler and the React page cannot disagree without someone failing to build.

Write data back with a form

Add a POST handler in api/hello.go:

type NoteCreate struct {
	Title string `json:"title"`
}

//borgo:route POST /api/notes
func CreateNote(w http.ResponseWriter, r *http.Request) {
	body, err := borgo.Bind[NoteCreate](r)
	if err != nil {
		borgo.BindError(w, err)
		return
	}
	notes = append(notes, Note{ID: len(notes) + 1, Title: body.Title})
	borgo.JSON(w, http.StatusCreated, Note{ID: len(notes), Title: body.Title})
}

borgo.Bind[NoteCreate] decodes the JSON body — and, like borgo.JSON, its type parameter is visible to the generator, so the request shape becomes typed too.

Then give the page an action and a form:

import { CsrfField, redirect, type ActionContext, type LoaderContext } from "borgo-framework";
import type { Note } from "@/.borgo/api-types";

export const head = { title: "Notes" };

export async function loader({ api }: LoaderContext) {
  const { notes } = await api("GET /api/notes");
  // a nil Go slice is null on the wire, so notes is Array<Note> | null
  return { notes: notes ?? [] };
}

export async function action({ request, api }: ActionContext) {
  const form = await request.formData();
  const title = String(form.get("title") ?? "").trim();
  if (!title) return { error: "give the note a title" };
  await api("POST /api/notes", { body: { title } });
  return redirect("/");
}

export default function Home({
  notes,
  actionData,
}: {
  notes: Note[];
  actionData?: { error?: string };
}) {
  return (
    <main>
      <h1>Notes</h1>
      <form method="post">
        <CsrfField />
        <input name="title" placeholder="A new note" />
        <button>Add</button>
      </form>
      {actionData?.error && <p className="error">{actionData.error}</p>}
      <ul>
        {notes.map((note) => (
          <li key={note.id}>{note.title}</li>
        ))}
      </ul>
      <a href="/about">About</a>
    </main>
  );
}

Add a note. Three things worth noticing:

  • The page did not reload and your scroll position did not jump — the runtime submits the form over fetch and re-renders in place.
  • Submitting an empty title shows the error, because returning an object from an action gives the page an actionData prop; returning redirect() re-runs the loader instead.
  • It also works with JavaScript disabled. Turn it off in your browser and add another note: the classic post/redirect/get cycle takes over. The enhancement is an enhancement, not a requirement.

<CsrfField /> is what makes a cross-site copy of this form fail. Note that the check is off by default in the dev session you are in right now — BORGO_CSRF=1 turns it on — and enforced in production, so a missing field is a bug you will not see until you deploy. See security for why.

Make one piece interactive

Server rendering covers most of a page, but some parts need the browser. Add islands/Counter.tsx:

import { useState } from "react";

export default function Counter() {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount((c) => c + 1)}>clicked {count} times</button>;
}

And drop it into pages/about.tsx, which now opts out of hydration entirely:

import { Island } from "borgo-framework";

export const head = { title: "About" };
export const hydrate = false;

export default function About() {
  return (
    <main>
      <h1>About</h1>
      <p>A notes app, built with borgo.</p>
      <Island name="Counter" />
      <a href="/">Back home</a>
    </main>
  );
}

Open /about with the network tab filtered to JS. The page's own component ships no client code — hydrate = false means exactly that — and what loads instead is the islands entry: React plus the island modules, and nothing of the page itself. The button still works.

Ship it

bun run build
bun run start

build compiles the client assets (one lazy chunk per route, precompressed), generates the types, and produces a single static Go binary in dist/. start runs both processes from that output. The scaffold also includes a Dockerfile and a docker-compose.yml, so docker compose up -d is a deployment.

One thing the build refuses — bun run dev runs the same build at boot, so you would have met it there first: a file referenced beside its own source. import pic from "./pic.svg" next to a page, or new URL("./pic.svg", import.meta.url), makes the bundler emit a url no route answers and makes the server render this machine's path into the HTML — so the build stops with 1 reference to a file beside its own source and leaves the tree marked unfinished, to be rebuilt rather than served. Files the browser fetches go in public/ and are named absolutely: /pic.svg.

Read security before a real one — it ends with a checklist — and deploy for the layouts and the environment reference.

What you have, and what you don't

You now have a server-rendered React app with a typed Go backend, client-side navigation, a form that works with and without JavaScript, partial hydration, and a production build that is two processes and no platform.

What this walkthrough left out, each with the page that covers it: nested layouts and error pages, streaming SSR, users and login, live updates over SSE and WebSockets, prefetching and scroll restoration (already working, worth knowing how), static export, and the dev loop in detail — fast refresh, borgo doctor, and the Tailwind option.

And the data is in a Go slice, so it disappears on restart. borgo imposes no database: reach for SQLite, Postgres, or whatever you like — it is a plain Go program.