Next Starter Logo
Tutorials

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.

You'll build a small notes feature end to end: a Prisma model, a migration, a validated Server Action that writes, and a dashboard page that reads.

Almost every pattern here comes straight out of the starter's own code. app/actions/settings.ts and the three files in app/dashboard/settings/ are the closest things to copy from.

The shape of a feature

Five files. Each one gets its own step below, and the sidebar link at the end makes six.

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. The auth tables get their ids from Better Auth, but this one generates its own, which is what @default(cuid()) does. The rest follows the starter's conventions: @@map to a lowercase table name, @@index on the foreign key, and onDelete: Cascade so deleting a user deletes their notes too.

prisma/schema.prisma
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-notes
pnpm prisma generate

The first writes the SQL migration and applies it to your local database. generate then rebuilds the typed client into generated/prisma, which is what puts prisma.note in your editor. Prisma 7's migrate dev doesn't run generate for you, so skipping it leaves you with a client that has no idea the table exists.

Define the validation schema

Schemas live in lib/validations/, away from the action, so the action and the form can import the same one. lib/validations/settings.ts has the same layout: the schema plus a z.infer type next to it. ApiResponse comes from types/api.ts and is { success, error?, message? }.

lib/validations/notes.ts
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

Your own mutations go through Server Actions rather than API routes. Four steps: check the session, validate the input, write to the database, return a typed { success, error? }. Without the try/catch around the body, a database failure would reach the browser as a thrown error instead of the { success: false } the form knows how to show. updateUserSettings in app/actions/settings.ts has this shape, and app/actions/files.ts shows the safeParse variant used below.

app/actions/notes.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> {
  try {
    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 };
  } catch {
    return { success: false, error: "Failed to save note" };
  }
}

lib/server/auth-helpers.ts wraps getSession in React's cache(), so the dashboard layout and every page under it can call it while the real lookup happens once per render. revalidatePath, called from a Server Action, updates the UI for that path immediately, which is how the new note appears without a reload. No shipped action calls it, because the settings and profile forms refetch the session on the client instead, and the users table calls router.refresh(). A page that renders rows out of Prisma needs one of the two, and revalidatePath keeps that logic in the action.

Build the dashboard page

Pages under app/dashboard/ are server components, so they can query Prisma directly, the way the settings page does. No fetch call, no API route. The layout at app/dashboard/layout.tsx already guards the whole section. It sends signed-out users to /auth/sign-in and anyone who hasn't finished onboarding to /onboarding.

Re-check the session anyway in any page that reads a user's own data. You need session.user.id to scope the query, and the check keeps the page safe on its own.

app/dashboard/notes/page.tsx
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>
  );
}

Two conventions this snippet leaves out: every other page under app/dashboard/ exports a metadata object built with generateMeta from lib/config.ts, and every one sits next to a loading.tsx skeleton. Copy both from app/dashboard/settings/.

The Prisma query runs per request, which is right for data a user expects fresh. Next.js 16 can cache it with "use cache", but that needs cacheComponents: true in next.config.ts and the starter doesn't set it.

Build the form

Every form in the starter is a client component built on React Hook Form + Zod. See app/dashboard/settings/settings-form.tsx and Forms for the pattern. This one reuses createNoteSchema from Step 3, so the browser and the server validate against a single schema.

useTransition drives the pending state here, the same way components/users/users-table.tsx does it. The settings form gets there with isSubmitting from formState. Either works.

app/dashboard/notes/note-form.tsx
"use client";

import { zodResolver } from "@hookform/resolvers/zod";
import { useTransition } from "react";
import { Controller, useForm } from "react-hook-form";
import { toast } from "sonner";
import { createNote } from "@/app/actions/notes";
import { Button } from "@/components/ui/button";
import { Field, FieldError } from "@/components/ui/field";
import { Textarea } from "@/components/ui/textarea";
import {
  type CreateNoteValues,
  createNoteSchema,
} from "@/lib/validations/notes";

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}
              aria-invalid={fieldState.invalid || undefined}
            />
            {fieldState.invalid && <FieldError errors={[fieldState.error]} />}
          </Field>
        )}
      />
      <Button type="submit" disabled={isPending}>
        {isPending ? "Saving..." : "Save note"}
      </Button>
    </form>
  );
}

Your page works, but nothing links to it. 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's group decides where the link lands: main at the top for everyone, system under a SYSTEM label that only renders for admins, secondary pinned to the bottom.

components/dashboard/sidebar.tsx
const navigationItems: NavigationItem[] = [
  { name: "Dashboard", href: "/dashboard", icon: Home, group: "main" },
  { name: "Notes", href: "/dashboard/notes", icon: StickyNote, group: "main" }, // add
  // ...rest
];

The pattern to keep

Server components read, Server Actions write. The notes feature is one turn of that loop, and the settings page and its action are another. The admin files page bends the rule by reading through a Server Action too, since it lists objects from R2 on the client, but the shape stays the same.

Related: Project Structure · Forms · Authentication · Database Setup

On this page