Tutorial

Build a notes app from scratch, in about twenty minutes:

  • a form built with @opentf/afw-form, with a ref and lifecycle hooks

  • one 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, $derived and $effect

  • a 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

Shell
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 ssr renders 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-form is 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:

JavaScriptapp/notes/schema.js
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.

JavaScriptapp/notes/notes.server.js
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.js is a plain module, so the browser can import it too; notes.server.js never 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.

JavaScriptapp/notes/loader.js
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

JSXapp/notes/NoteForm.jsx
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>
  );
}
  • createForm holds the field's value, its error and the submit state, all reactive.

  • register("text") gives the input its name, value and event handlers.

  • handleSubmit runs validate first, 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.isSubmitting disables the button while saving, so a double click saves once.

  • If the server still refuses the note, setError shows its message on the field. reset() clears the input after a save.

  • The component compiles to a Custom Element. save is a prop, set by the page.

  • $ref() holds the <input> element once it is in the DOM.

  • onMount runs once the component is connected: it focuses the input and listens for the shortcut.

  • onCleanup runs 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

JSXapp/notes/page.jsx
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>
  );
}
  • $state makes filter reactive: assigning it updates only what reads it.

  • $derived recomputes notes when router.data changes, and shown when either notes or filter does.

  • $effect re-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 addNote from notes.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:

JSXapp/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.

Prefetching

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.

JavaScriptapp/notes/[id]/loader.js
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 };
}
JSXapp/notes/[id]/page.jsx
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 as router.params.id.

  • notFound() answers with the 404 page and HTTP status 404, so /notes/999 is a real 404, not an empty page.

  • Click a note in the list to open its page. See Dynamic Routes.

7. Test the component

JavaScripttests/NoteForm.test.js
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);
});
Shell
npm test
  • render mounts a component in a DOM, which runs its onMount; the queries and userEvent come from Testing Library.

  • The tests pass a fake save, so they need no server.

8. Build and run in production

Shell
npm run build
npm start
  • npm run build writes the browser assets to dist/client and a Node server to dist/server.

  • npm start runs node dist/server/index.js. PORT and HOST set where it listens.

  • To deploy to any Node 22+ host:

Shell
npm ci
npm run build
npm prune --omit=dev
npm start
  • The 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) from node_modules.

Next

  • Forms: nested fields, arrays and validation timing.

  • Lifecycle and Reactivity: the other hooks, and $state, $derived and $effect in depth.

  • Server Functions: authentication, passing locals from 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.

Last updated on
Edit this page