Recipes
For the complete documentation index, see llms.txt. Prefer markdown by appending .md to documentation URLs or sending Accept: text/markdown.

Add a feature

Build a user-owned notes feature from a D1 table through protected server functions, query caching, translated copy, and a dashboard route.

This recipe adds a small notes feature. Each user can create a note and list their own notes. You connect the database, server boundary, client cache, and translated UI using the same layers as the shipped billing module.

All notes files below are new example files, not an existing kit feature. Finish local development setup first.

Follow the module conventions for imports and file boundaries. Register the new URL in paths configuration so navigation uses the same route constant.

Create and migrate the table

Follow schema changes to create src/db/schema/note.ts and export it from src/db/schema/index.ts. The database client already imports that barrel. That table contains id, userId, and title, with a foreign key to the auth user.

Generate and apply its migration locally:

pnpm exec drizzle-kit generate
pnpm db:migrate --local

Inspect the SQL before applying it. Your custom table stays outside src/db/schema/auth.ts, which the auth generator overwrites.

Add the translated copy

Insert these entries into the existing English catalog:

messages/en.json
"notes.title": "Your notes",
"notes.empty": "You have no notes yet.",
"notes.titleLabel": "Note title",
"notes.titlePlaceholder": "A short title",
"notes.loading": "Loading notes..."

And their Spanish translations:

messages/es.json
"notes.title": "Tus notas",
"notes.empty": "Todavía no tienes notas.",
"notes.titleLabel": "Título de la nota",
"notes.titlePlaceholder": "Un título breve",
"notes.loading": "Cargando notas..."

Let pnpm dev regenerate the message functions, or run the compiler from translations. The shared save and error.general messages already exist.

Validate input and keep database access on the Worker

src/modules/notes/notes.schema.ts
import * as z from "zod";

export const createNoteSchema = z.object({
  title: z.string().trim().min(1).max(120),
});

The schema accepts a title, not an owner ID. Both database operations derive the owner from the authenticated caller:

src/modules/notes/notes.server.ts
import { eq } from "drizzle-orm";

import { db } from "@/db";
import { note } from "@/db/schema";

export function listNotes(userId: string) {
  return db
    .select({ id: note.id, title: note.title })
    .from(note)
    .where(eq(note.userId, userId));
}

export async function createNote(userId: string, title: string) {
  const id = crypto.randomUUID();
  await db.insert(note).values({ id, userId, title });
  return { id, title };
}

Expose the services through server functions:

src/modules/notes/notes.functions.ts
import { createServerFn } from "@tanstack/react-start";

import { enforceAuth } from "@/lib/auth/middleware";

import { createNoteSchema } from "./notes.schema";
import { createNote, listNotes } from "./notes.server";

export const listNotesFn = createServerFn({ method: "GET" })
  .middleware([enforceAuth])
  .handler(({ context }) => listNotes(context.user.id));

export const createNoteFn = createServerFn({ method: "POST" })
  .middleware([enforceAuth])
  .validator(createNoteSchema)
  .handler(({ data, context }) => createNote(context.user.id, data.title));

This example accepts anonymous sessions, like the existing auth middleware. If your notes require a verified account or paid plan, add that requirement on both server operations. See security and plan gating.

Connect the client cache

Use the current user's ID in the query key to keep cached results separate when the signed-in user changes. It selects a cache entry; the server still resolves ownership independently.

src/modules/notes/notes.queries.ts
import { mutationOptions, queryOptions } from "@tanstack/react-query";

import { createNoteFn, listNotesFn } from "./notes.functions";

export const notes = {
  queries: {
    list: (userId: string) =>
      queryOptions({
        queryKey: ["notes", userId],
        queryFn: () => listNotesFn(),
      }),
  },
  mutations: {
    create: mutationOptions({
      mutationFn: (title: string) => createNoteFn({ data: { title } }),
    }),
  },
};

Add the dashboard route

Create src/routes/dashboard/notes.tsx. The parent dashboard route supplies session through route context; each server function still checks authentication itself.

src/routes/dashboard/notes.tsx
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
import { useState } from "react";

import { m } from "@/lib/i18n/messages.js";
import { notes } from "@/modules/notes/notes.queries";

export const Route = createFileRoute("/dashboard/notes")({
  component: NotesPage,
});

function NotesPage() {
  const { session } = Route.useRouteContext();
  const queryClient = useQueryClient();
  const [title, setTitle] = useState("");
  const options = notes.queries.list(session.user.id);
  const list = useQuery(options);
  const create = useMutation({
    ...notes.mutations.create,
    onSuccess: async () => {
      setTitle("");
      await queryClient.invalidateQueries({ queryKey: options.queryKey });
    },
  });

  return (
    <section className="space-y-6">
      <h1 className="text-2xl font-semibold">{m["notes.title"]()}</h1>
      <form
        className="flex flex-col gap-3"
        onSubmit={(event) => {
          event.preventDefault();
          if (title.trim() && !create.isPending) {
            create.mutate(title);
          }
        }}
      >
        <label htmlFor="note-title">{m["notes.titleLabel"]()}</label>
        <input
          id="note-title"
          className="rounded border px-3 py-2"
          value={title}
          onChange={(event) => setTitle(event.target.value)}
          placeholder={m["notes.titlePlaceholder"]()}
          maxLength={120}
          required
        />
        <button
          className="rounded border px-3 py-2 disabled:opacity-50"
          type="submit"
          disabled={create.isPending || !title.trim()}
        >
          {m.save()}
        </button>
      </form>
      {list.isPending ? <p>{m["notes.loading"]()}</p> : null}
      {list.isError || create.isError ? (
        <p role="alert">{m["error.general"]()}</p>
      ) : null}
      {list.data?.length === 0 ? <p>{m["notes.empty"]()}</p> : null}
      <ul className="space-y-2">
        {list.data?.map((record) => (
          <li key={record.id}>{record.title}</li>
        ))}
      </ul>
    </section>
  );
}

Run pnpm generate-routes or let Vite regenerate the route tree. Do not edit src/routeTree.gen.ts manually. In getMenu() in src/routes/dashboard/route.tsx, add an item to the overview group's items:

{
  id: "notes",
  title: m["notes.title"](),
  href: "/dashboard/notes",
}

You can move that path into src/config/paths.ts as your module grows. The existing dashboard shell handles localization and the surrounding navigation.

Verify the feature before deploying

Run pnpm lint, pnpm test, and pnpm build. Open /dashboard/notes, create a note, and reload to confirm that it comes from D1. Switch to Spanish and check labels and empty states.

Sign in as a second user and confirm the first user's notes do not appear. Call the create function without a session, send a blank title, and send a title longer than 120 characters. The server should reject each invalid case independently of the form.

Add the meaningful ownership and input cases to your tests, then extend a browser spec for creating and reloading a note. For updates or deletes, reuse the owner-and-ID predicate in protected calls.

Deploy the reviewed migration with the feature using migration deployment. The production seed is not part of this workflow.

How is this guide?

Last updated on

On this page

Ship globally on the edge. In minutes.Try Edge Kit