Next Starter Logo
Tutorials

Build Your First SaaS Feature

Step-by-step tutorial for building an auth-protected dashboard feature using Prisma, server actions, and shadcn/ui, covering the full stack from database model to UI form.

This tutorial walks through building a "notes" feature from scratch. You will touch every layer of the stack: the database model, a server action for mutations, a server component page that reads data, and the UI built with shadcn/ui components. By the end, you will have a working, auth-protected notes section in the dashboard.

Overview

The steps involved are:

  1. Add a Note model to prisma/schema.prisma
  2. Run the database migration
  3. Create a server action to create notes
  4. Build the dashboard page as a server component
  5. Build the UI form and list
  6. Protect the route with authentication
  7. Add a sidebar link to the new page

Step 1: Add the Prisma Model

Open prisma/schema.prisma and add the Note model. It belongs to a User, so add the relation on both sides.

prisma/schema.prisma
model Note {
  id        String   @id @default(cuid())
  content   String
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
  userId    String
  user      User     @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@index([userId])
  @@map("note")
}

Also add the inverse relation to the existing User model:

prisma/schema.prisma
model User {
  // ... existing fields
  notes     Note[]
}

Step 2: Run the Migration

Apply the schema change to your local database:

pnpm dlx prisma migrate dev --name add-notes

Prisma generates and runs the SQL migration, then regenerates the client in generated/prisma.


Step 3: Create the Server Action

Server actions handle mutations. Create a new file at app/actions/notes.ts.

app/actions/notes.ts
"use server";

import { revalidatePath } from "next/cache";
import prisma from "@/lib/db";
import { getSession } from "@/lib/server/auth-helpers";
import { z } from "zod";

const createNoteSchema = z.object({
  content: z.string().min(1).max(500),
});

export async function createNote(data: unknown) {
  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 };
}

Key points:

  • "use server" at the top marks this as a server action module
  • Always validate the session before touching the database
  • revalidatePath tells Next.js to invalidate the cached page so the new note appears immediately

Step 4: Build the Page

Create the page at app/dashboard/notes/page.tsx. This is a server component, so it can query Prisma directly.

app/dashboard/notes/page.tsx
import { redirect } from "next/navigation";
import { getSession } from "@/lib/server/auth-helpers";
import prisma from "@/lib/db";
import { NoteForm } from "./note-form";

export default async function NotesPage() {
  const session = await getSession();
  if (!session) 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">
      <div>
        <h1 className="text-2xl font-semibold">Notes</h1>
        <p className="text-muted-foreground mt-1">
          Keep track of your thoughts.
        </p>
      </div>

      <NoteForm />

      <ul className="space-y-3">
        {notes.map((note) => (
          <li
            key={note.id}
            className="rounded-lg border bg-card p-4 text-sm text-card-foreground"
          >
            <p>{note.content}</p>
            <p className="mt-2 text-xs text-muted-foreground">
              {note.createdAt.toLocaleDateString()}
            </p>
          </li>
        ))}
        {notes.length === 0 && (
          <p className="text-sm text-muted-foreground">No notes yet.</p>
        )}
      </ul>
    </div>
  );
}

Why direct Prisma queries?

Server components run server-side code, so no API route is needed. Next.js fetches data at render time and streams it to the client as HTML. The query runs on every request by default. If your data does not need to be fresh on every request, cache the output using the "use cache" directive. Note that this requires cacheComponents: true in your next.config.ts first (see the Next.js caching docs).


Step 5: Build the Form Component

The form must be a client component because it handles user interaction. Create app/dashboard/notes/note-form.tsx.

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

import { useTransition } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import {
  Form,
  FormControl,
  FormField,
  FormItem,
  FormMessage,
} from "@/components/ui/form";
import { createNote } from "@/app/actions/notes";

const formSchema = z.object({
  content: z.string().min(1, "Note cannot be empty").max(500),
});

type FormValues = z.infer<typeof formSchema>;

export function NoteForm() {
  const [isPending, startTransition] = useTransition();

  const form = useForm<FormValues>({
    resolver: zodResolver(formSchema),
    defaultValues: { content: "" },
  });

  function onSubmit(values: FormValues) {
    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 {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-3">
        <FormField
          control={form.control}
          name="content"
          render={({ field }) => (
            <FormItem>
              <FormControl>
                <Textarea
                  placeholder="Write a note..."
                  className="resize-none"
                  rows={3}
                  {...field}
                />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        <Button type="submit" disabled={isPending}>
          {isPending ? "Saving..." : "Save note"}
        </Button>
      </form>
    </Form>
  );
}

This follows the React Hook Form + Zod pattern used throughout the codebase. The useTransition hook keeps the UI responsive while the server action runs.


Step 6: Add Authentication Protection

The app/dashboard/ directory is already protected by the dashboard layout at app/dashboard/layout.tsx, which redirects unauthenticated users to /auth/sign-in. Any page you create inside app/dashboard/ inherits this protection automatically.

The notes page also re-checks the session and redirects. This second check is required for any page that queries user-specific data directly.


Open components/dashboard/sidebar.tsx and add the notes item to the navigationItems array.

Add StickyNote to the existing lucide-react import at the top of the file, then add the entry:

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 this
  // ... rest of items
];

The Full Flow

With all pieces in place, here is what happens when a user submits a note:

  1. NoteForm calls createNote(values), a server action
  2. The server action validates the session, validates the input with Zod, and writes to the database
  3. revalidatePath("/dashboard/notes") marks the page cache as stale
  4. Next.js re-renders NotesPage on the next request, which queries the updated list from Prisma
  5. The new note appears in the list

This pattern, server component reads and server action writes, scales to any feature you build.

On this page