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:
- Add a
Notemodel toprisma/schema.prisma - Run the database migration
- Create a server action to create notes
- Build the dashboard page as a server component
- Build the UI form and list
- Protect the route with authentication
- 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.
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:
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-notesPrisma 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.
"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
revalidatePathtells 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.
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.
"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.
Step 7: Add the Sidebar Link
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:
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:
NoteFormcallscreateNote(values), a server action- The server action validates the session, validates the input with Zod, and writes to the database
revalidatePath("/dashboard/notes")marks the page cache as stale- Next.js re-renders
NotesPageon the next request, which queries the updated list from Prisma - The new note appears in the list
This pattern, server component reads and server action writes, scales to any feature you build.
Project Structure
A reference for the Next Starter directory layout, every folder, key file, and the conventions that keep the codebase consistent as it grows.
Adding a New Page
Step-by-step guide to adding authenticated dashboard pages and public marketing pages in Next Starter, including metadata, sidebar navigation, and breadcrumb support.