Build Your First SaaS Feature
Build your first SaaS feature in Next.js: a Prisma model, a migration, a validated Server Action, and an auth-protected dashboard page for a notes feature.
This tutorial builds a small notes feature from start to finish. You'll touch every layer of the starter: a Prisma model, a migration, a validated Server Action that writes data, and a dashboard page that reads it. Don't worry if a step looks new. Each one is short, and you can copy it directly.
Every pattern here matches what the starter already ships. The closest files to copy from are app/actions/settings.ts (the Server Action) and app/dashboard/settings/ (the page and form).
The shape of a feature
prisma/schema.prisma → data model
lib/validations/notes.ts → Zod schema + typed result
app/actions/notes.ts → Server Action (validate → mutate)
app/dashboard/notes/page.tsx→ server component (reads + renders)
app/dashboard/notes/note-form.tsx → client form (writes)Add the Prisma model
Add a Note model to prisma/schema.prisma, plus a matching notes field on User so the relation points both ways. This table is yours, so unlike the auth tables (whose ids come from Better Auth) it generates its own id, which is what @default(cuid()) does. The rest follows the starter's conventions: @@map to a snake_case table name, @@index on the foreign key, and onDelete: Cascade so a user's notes are removed when the user is deleted.
See Project Structure for where the schema and generated client live, and Database Setup for connecting Postgres.
model Note {
id String @id @default(cuid())
content String
createdAt DateTime @default(now())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@map("note")
}
model User {
// ...existing fields
notes Note[]
}Run the migration
pnpm prisma migrate dev --name add-notesThis does three things in one command: it writes the SQL migration file, applies it to your local database, and regenerates the typed Prisma client into generated/prisma. That last step is why prisma.note is available in your code right after the command finishes.
Define the validation schema
Validation schemas live in lib/validations/, kept separate from the action. That way both the action and the form can import the same schema and its inferred types. The action's return type uses the shared ApiResponse type from types/api.ts ({ success, error?, message? }).
import { z } from "zod";
import type { ApiResponse } from "@/types/api";
export const createNoteSchema = z.object({
content: z.string().min(1, "Note cannot be empty").max(500),
});
export type CreateNoteValues = z.infer<typeof createNoteSchema>;
export type CreateNoteResponse = ApiResponse;Write the Server Action
Mutations go through Server Actions, not API routes. Every action follows the same four-step contract: check the session, validate the input, write to the database, and return a typed { success, error? }. It never throws to the client, so the form always gets a clean result to react to. This mirrors updateUserSettings in app/actions/settings.ts.
"use server";
import { revalidatePath } from "next/cache";
import prisma from "@/lib/db";
import { getSession } from "@/lib/server/auth-helpers";
import {
type CreateNoteResponse,
createNoteSchema,
} from "@/lib/validations/notes";
export async function createNote(data: unknown): Promise<CreateNoteResponse> {
const session = await getSession();
if (!session) return { success: false, error: "Unauthorized" };
const parsed = createNoteSchema.safeParse(data);
if (!parsed.success) return { success: false, error: "Invalid input" };
await prisma.note.create({
data: { content: parsed.data.content, userId: session.user.id },
});
revalidatePath("/dashboard/notes");
return { success: true };
}getSession (from lib/server/auth-helpers.ts) is wrapped in React's cache(). That means calling it more than once in the same request (for example in both the action and the page) runs the real lookup only once. revalidatePath clears the page's cached output so the new note shows on the next render. Want more reference actions? Read app/actions/contact.ts and app/actions/settings.ts.
Build the dashboard page
Pages under app/dashboard/ are server components, so they query Prisma directly. No fetch call, no API route. The dashboard layout (app/dashboard/layout.tsx) already protects the whole section: it redirects signed-out users to /auth/sign-in, and users who haven't finished onboarding to /onboarding. So every nested page is already behind auth.
Even so, re-check the session inside any page that reads a user's own data. You need session.user.id to scope the query, and the check keeps each page safe on its own.
import { redirect } from "next/navigation";
import prisma from "@/lib/db";
import { getSession } from "@/lib/server/auth-helpers";
import { NoteForm } from "./note-form";
export default async function NotesPage() {
const session = await getSession();
if (!session?.user) redirect("/auth/sign-in");
const notes = await prisma.note.findMany({
where: { userId: session.user.id },
orderBy: { createdAt: "desc" },
});
return (
<div className="max-w-2xl space-y-6">
<h1 className="text-2xl font-semibold">Notes</h1>
<NoteForm />
<ul className="space-y-3">
{notes.map((note) => (
<li key={note.id} className="rounded-lg border bg-card p-4 text-sm">
{note.content}
</li>
))}
{notes.length === 0 && (
<p className="text-muted-foreground text-sm">No notes yet.</p>
)}
</ul>
</div>
);
}By default this query runs on every request, which is what you want for data a user expects to see fresh. If you later have a page whose data rarely changes, Next.js 16 can cache it with the "use cache" directive. That is opt-in and not enabled in the starter, so leave the per-request query as-is unless you turn on cacheComponents in next.config.ts yourself.
Build the form
The form is a client component because it handles user input and submit state. It uses the React Hook Form + Zod pattern that every form in the starter follows (see app/dashboard/settings/settings-form.tsx and Forms). It reuses the same createNoteSchema from Step 3, so the browser and the server validate against one schema.
For the pending state, this form uses useTransition to disable the button while the action runs. The settings form reaches the same goal a slightly different way, with isSubmitting from React Hook Form's formState. Either approach is fine.
"use client";
import { useTransition } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { toast } from "sonner";
import { createNote } from "@/app/actions/notes";
import {
type CreateNoteValues,
createNoteSchema,
} from "@/lib/validations/notes";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Field, FieldError } from "@/components/ui/field";
export function NoteForm() {
const [isPending, startTransition] = useTransition();
const form = useForm<CreateNoteValues>({
resolver: zodResolver(createNoteSchema),
defaultValues: { content: "" },
});
function onSubmit(values: CreateNoteValues) {
startTransition(async () => {
const result = await createNote(values);
if (result.success) {
form.reset();
toast.success("Note saved");
} else {
toast.error(result.error ?? "Something went wrong");
}
});
}
return (
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-3">
<Controller
control={form.control}
name="content"
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid || undefined}>
<Textarea {...field} placeholder="Write a note..." rows={3} />
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</Field>
)}
/>
<Button type="submit" disabled={isPending}>
{isPending ? "Saving..." : "Save note"}
</Button>
</form>
);
}Add the sidebar link
Your page works now, but nothing links to it yet. Add an entry to the navigationItems array in components/dashboard/sidebar.tsx, and import its lucide-react icon at the top of that file. Each item sets a group (main, system, or secondary) that decides where the link appears. Use main for a link everyone should see; the system group only renders for admins.
const navigationItems: NavigationItem[] = [
{ name: "Dashboard", href: "/dashboard", icon: Home, group: "main" },
{ name: "Notes", href: "/dashboard/notes", icon: StickyNote, group: "main" }, // add
// ...rest
];What just happened
Here's the full round trip when a user saves a note:
- The form calls the
createNoteServer Action. - The action checks the session, validates the input, and writes the note to Postgres.
revalidatePathmarks the notes page stale.- On the next render, the page re-queries Prisma and the new note appears.
The pattern to remember is simple: server components read, Server Actions write. Once it clicks, you can build any feature on the starter the same way.
Go deeper: Project Structure · Forms · Authentication · Database Setup
Project Structure
A map of the Next Starter directory layout: every folder and file in this Next.js SaaS kit, plus conventions for where new pages, components, and actions go.
Adding a New Page
How to add a new page in Next.js with the App Router. Where the file goes, how layouts and auth apply, and how to set page metadata and navigation links.