Skip to main content
← Back to blog

Building Your First Feature with the Starter

A practical walkthrough of adding a feature end-to-end — database schema, server action, and UI — using the patterns baked into the starter.

by The Team

tutorialdrizzleserver-actions

The quickest way to understand any codebase is to build something small in it. This post walks through adding a feature end-to-end: a simple notes model scoped to each organisation. By the end you'll have touched the schema, a server action, and a page — and you'll see how the starter's conventions keep each layer thin.

1. Schema

Drizzle schema files live in src/db/schema/. Add notes.ts:

import { pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";

export const notes = pgTable("notes", {
  id: uuid("id").primaryKey().defaultRandom(),
  accountId: uuid("account_id").notNull(),
  body: text("body").notNull(),
  createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
});

Then run:

pnpm db:generate   # creates the migration file
pnpm db:migrate    # applies it to your Neon branch

Neon's row-level security policies automatically scope reads to the active account — no WHERE accountId = ? needed in queries once withAccount() is called in the layout.

2. Server action

Create src/lib/notes/actions.ts:

"use server";

import { db } from "@/db";
import { notes } from "@/db/schema/notes";
import { getActiveAccount } from "@/lib/tenancy/server";
import { revalidatePath } from "next/cache";

export async function createNote(formData: FormData) {
  const account = await getActiveAccount();
  const body = formData.get("body");

  if (typeof body !== "string" || body.trim() === "") {
    return { error: "Note body is required." };
  }

  await db.insert(notes).values({ accountId: account.id, body: body.trim() });
  revalidatePath("/dashboard/notes");
}

Server actions in Next.js 16 colocate mutation logic with the route that uses it. No separate API route needed.

3. Page

// src/app/(app)/(dashboard)/notes/page.tsx
import { db } from "@/db";
import { notes } from "@/db/schema/notes";
import { createNote } from "@/lib/notes/actions";

export default async function NotesPage() {
  const rows = await db.select().from(notes).orderBy(notes.createdAt);

  return (
    <div className="space-y-6">
      <h1 className="font-bold text-2xl">Notes</h1>

      <form action={createNote} className="flex gap-2">
        <input name="body" className="flex-1 rounded-md border px-3 py-2 text-sm" placeholder="Write a note…" />
        <button type="submit" className="rounded-md bg-primary px-4 py-2 text-primary-foreground text-sm">
          Add
        </button>
      </form>

      <ul className="divide-y divide-border rounded-xl border">
        {rows.map((note) => (
          <li key={note.id} className="px-4 py-3 text-sm">
            {note.body}
          </li>
        ))}
      </ul>
    </div>
  );
}

What just happened

  • Schema — one file, one migration command. Drizzle generates the SQL.
  • Action"use server" makes it a server action. The form's action prop wires it without any client JS.
  • RLS — the Neon RLS policy attached to notes automatically filters rows by accountId, so the SELECT above never leaks data across orgs.

That's the core loop. From here you'd add Zod validation to the action, an optimistic UI update on the client, and a DELETE action — all following the same pattern.

Next steps

  • Read the configuration guide to understand environment variables and feature flags.
  • Check src/lib/stripe/ to see how billing state is read in layouts.
  • Browse src/components/ui/ for pre-built shadcn/ui components styled to the brand theme.