Tutorial
Build a notes app from scratch, in about twenty minutes:
a form built with
@opentf/afw-form, with a ref and lifecycle hooksone Zod schema that checks a note in the browser and again on the server
a loader that renders the notes on the server
server functions to add and delete notes
a page with its own state, reacting with
$state,$derivedand$effecta dynamic route with a 404
a component test
a production build that runs on Node
The tutorial uses the Vite + Node workflow and JavaScript. On esdev the app/ code is the same; only the server entry differs (see Server Setup).
1. Create the app
npm create @opentf/afw@latest my-notes -- --template app --render ssr --language js
cd my-notes
npm install zod @opentf/afw-form
npm run dev--render ssrrenders pages on the server per request and hydrates them in the browser. It always includes a Node server.The starter already has a home page, an About page and a todos example. Open
http://localhost:5173.Zod validates a note. Any Standard Schema library works.
@opentf/afw-formis AFW's form library: field binding, validation and submit state.
2. Write the server functions
One schema describes a valid note. Both the form and the server import it:
import { z } from "zod";
export const noteSchema = z.object({ text: z.string().trim().min(1, "Write something first") });A *.server.js file runs only on the server. Its query() and action() exports become functions the browser can call; everything else in it stays on the server.
import { action, query } from "@opentf/afw/server";
import { z } from "zod";
import { noteSchema } from "./schema.js";
// In memory for the tutorial: a real app keeps notes in its database.
const notes = [{ id: 1, text: "Read the AFW docs" }];
let nextId = 2;
export const listNotes = query(async () => notes);
export const addNote = action(noteSchema, async ({ text }) => {
const note = { id: nextId++, text };
notes.push(note);
return note;
});
export const deleteNote = action(z.object({ id: z.number() }), async ({ id }) => {
const index = notes.findIndex((note) => note.id === id);
if (index !== -1) notes.splice(index, 1);
});query()is for reads,action()for writes.With a schema, the input is validated on the server before the function runs. Invalid input never reaches it: the call fails with the schema's issues.
schema.jsis a plain module, so the browser can import it too;notes.server.jsnever reaches the browser.
3. Load the notes on the server
A loader next to a page runs on the server before the page renders. Its result is the page's router.data.
import { listNotes } from "./notes.server.js";
export default async function loader() {
return { notes: await listNotes() };
}A server function called from server code is a plain async call: no HTTP request.
4. Build the form component
import { createForm } from "@opentf/afw-form";
import { noteSchema } from "./schema.js";
export default function NoteForm({ save }) {
const form = createForm({
initialValues: { text: "" },
mode: "onSubmit",
validate: (values) => {
const result = noteSchema.safeParse(values);
return result.success ? {} : { text: result.error.issues[0].message };
},
});
const input = $ref();
// "/" jumps to the input, unless you are already typing in a field.
function shortcut(event) {
if (event.key === "/" && event.target.tagName !== "INPUT") {
event.preventDefault();
input.focus();
}
}
onMount(() => {
input.focus();
document.addEventListener("keydown", shortcut);
});
onCleanup(() => document.removeEventListener("keydown", shortcut));
const submit = form.handleSubmit(async ({ text }) => {
try {
await save(text);
form.reset();
} catch (e) {
form.setError("text", e.issues?.[0]?.message ?? e.message);
}
});
return (
<form onsubmit={submit}>
<input ref={input} {...form.register("text")} placeholder="New note (press /)" aria-label="New note" />
<button type="submit" disabled={form.isSubmitting}>Add</button>
{form.errors.text ? <p role="alert">{form.errors.text}</p> : null}
</form>
);
}createFormholds the field's value, its error and the submit state, all reactive.register("text")gives the input itsname,valueand event handlers.handleSubmitrunsvalidatefirst, so an empty note is refused in the browser without calling the server, and with the same message the server would send.mode: "onSubmit"checks only on submit, so the focused, empty input shows no error before you have typed.form.isSubmittingdisables the button while saving, so a double click saves once.If the server still refuses the note,
setErrorshows its message on the field.reset()clears the input after a save.The component compiles to a Custom Element.
saveis a prop, set by the page.$ref()holds the<input>element once it is in the DOM.onMountruns once the component is connected: it focuses the input and listens for the shortcut.onCleanupruns when the component is removed — here, when you navigate away — so the listener does not outlive the page.Neither hook runs on the server; see Lifecycle.
More in Forms.
5. Build the page
import { Link, invalidate, router } from "@opentf/afw";
import NoteForm from "./NoteForm.jsx";
import { addNote, deleteNote } from "./notes.server.js";
export const metadata = { title: "Notes" };
export default function Notes() {
let filter = $state("");
const notes = $derived(router.data?.notes ?? []);
const shown = $derived(notes.filter((note) => note.text.toLowerCase().includes(filter.toLowerCase())));
$effect(() => {
document.title = `Notes (${notes.length})`;
});
async function save(text) {
await addNote({ text });
await invalidate(); // reload this page's loader data
}
async function remove(id) {
await deleteNote({ id });
await invalidate();
}
return (
<section>
<h1>Notes</h1>
<input type="search" value={filter} oninput={(e) => (filter = e.target.value)} placeholder="Filter" aria-label="Filter notes" />
<ul>
{shown.map((note) => (
<li key={note.id}>
<Link href={`/notes/${note.id}`}>{note.text}</Link> <button onclick={() => remove(note.id)}>Delete</button>
</li>
))}
</ul>
<NoteForm save={save} />
</section>
);
}$statemakesfilterreactive: assigning it updates only what reads it.$derivedrecomputesnoteswhenrouter.datachanges, andshownwhen eithernotesorfilterdoes.$effectre-runs whenever a signal it reads changes: the tab title follows the count.key={note.id}lets a list update move the existing rows instead of rebuilding them.The page imports
addNotefromnotes.server.js, but the browser gets a small fetch stub, not the server code. Calls need no URL.invalidate()refetches the page's loader data after a change, so the list updates.Add the page to the navigation in
app/layout.jsx:
import { Link } from "@opentf/afw";
export default function Layout({ children }) {
return (
<>
<nav>
<Link href="/">Home</Link>
<Link href="/about">About</Link>
<Link href="/todos">Todos</Link>
<Link href="/notes">Notes</Link>
</nav>
<main>{children}</main>
</>
);
}Open /notes:
the list is in the server-rendered HTML;
adding or deleting a note updates it without a reload, and the tab title follows;
typing in the filter narrows the list as you type;
an empty note shows "Write something first", from the schema, without a request;
press
/anywhere on the page to jump to the input.
Hover the Notes link: <Link> loads the page's code and its loader data before the click, so the navigation has nothing left to wait for.
6. Add a page per note
A folder in brackets captures a path segment: app/notes/[id]/ answers /notes/1, /notes/2, and so on.
import { notFound } from "@opentf/afw/server";
import { listNotes } from "../notes.server.js";
export default async function loader({ params }) {
const note = (await listNotes()).find((n) => n.id === Number(params.id));
if (!note) notFound();
return { note };
}import { Link, router } from "@opentf/afw";
export default function Note() {
return (
<article>
<h1>{router.data?.note?.text}</h1>
<p>Note #{router.params.id}</p>
<Link href="/notes">All notes</Link>
</article>
);
}The loader gets the segment as
params.id; the page reads it asrouter.params.id.notFound()answers with the 404 page and HTTP status 404, so/notes/999is a real 404, not an empty page.Click a note in the list to open its page. See Dynamic Routes.
7. Test the component
import { expect, test, vi } from "vitest";
import { render, userEvent } from "@opentf/afw-test";
import NoteForm from "../app/notes/NoteForm.jsx";
test("saves the typed note and clears the input", async () => {
const save = vi.fn(async () => {});
const { getByRole } = render(NoteForm, { save });
const user = userEvent.setup();
await user.type(getByRole("textbox", { name: "New note" }), "Buy milk");
await user.click(getByRole("button", { name: "Add" }));
expect(save).toHaveBeenCalledWith("Buy milk");
expect(getByRole("textbox").value).toBe("");
});
test("refuses an empty note without calling the server", async () => {
const save = vi.fn(async () => {});
const { getByRole, findByRole } = render(NoteForm, { save });
await userEvent.setup().click(getByRole("button", { name: "Add" }));
expect((await findByRole("alert")).textContent).toBe("Write something first");
expect(save).not.toHaveBeenCalled();
});
test("shows the server's refusal on the field", async () => {
const save = async () => {
throw Object.assign(new Error("Invalid input"), { issues: [{ message: "Too many notes" }] });
};
const { getByRole, findByRole } = render(NoteForm, { save });
const user = userEvent.setup();
await user.type(getByRole("textbox", { name: "New note" }), "One more");
await user.click(getByRole("button", { name: "Add" }));
expect((await findByRole("alert")).textContent).toBe("Too many notes");
});
test("/ focuses the input", async () => {
const { getByRole } = render(NoteForm, { save: async () => {} });
const input = getByRole("textbox", { name: "New note" });
input.blur();
await userEvent.setup().keyboard("/");
expect(document.activeElement).toBe(input);
});npm testrendermounts a component in a DOM, which runs itsonMount; the queries anduserEventcome from Testing Library.The tests pass a fake
save, so they need no server.
8. Build and run in production
npm run build
npm startnpm run buildwrites the browser assets todist/clientand a Node server todist/server.npm startrunsnode dist/server/index.js.PORTandHOSTset where it listens.To deploy to any Node 22+ host:
npm ci
npm run build
npm prune --omit=dev
npm startThe build needs the dev dependencies (Vite and the AFW plugin); the running server does not, so prune them after building. It still imports its dependencies (
zod,@opentf/afw,@opentf/afw-form) fromnode_modules.
Next
Forms: nested fields, arrays and validation timing.
Lifecycle and Reactivity: the other hooks, and
$state,$derivedand$effectin depth.Server Functions: authentication, passing
localsfrom middleware, errors and everything that can be sent.Data Fetching: loaders and
resource().Middleware: sign users in and guard sections.
Deployment: static hosting, Node and Fetch-native runtimes.