Introduction: What We're Building
In this tutorial, you’ll build a complete note-taking application using Next.js (App Router) and Prisma. You’ll learn how to define a database schema, run migrations, create server actions for CRUD operations, and render a responsive UI with server and client components. By the end, you’ll have a working app where you can create, read, update, and delete notes, all backed by a real database.
Why This Stack Matters
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →Next.js provides a modern React framework with server-side rendering, file-based routing, and built-in API capabilities. Prisma is a type-safe ORM that simplifies database access and migrations. Together they give you a productive full-stack development experience with end-to-end type safety, automatic caching, and seamless integration between the data layer and the UI. This pattern is ideal for production apps that need fast iteration, maintainable code, and robust data handling.
Project Setup and Prisma Initialization
Step 1: Create a New Next.js Project
Use the create-next-app CLI with TypeScript and Tailwind CSS for styling. Choose the App Router when prompted.
npx create-next-app@latest notes-app --typescript --tailwind --eslint
Navigate into the project folder:
cd notes-app
Step 2: Install Prisma and Dependencies
Install Prisma and Zod for server-side validation:
npm install prisma @prisma/client zod
Step 3: Initialize Prisma with SQLite
We'll use SQLite for simplicity (zero configuration). You can swap it for PostgreSQL or MySQL later.
npx prisma init --datasource-provider sqlite
This command creates a prisma/ directory containing schema.prisma and a .env file with a DATABASE_URL pointing to file:./dev.db.
Defining the Data Model
Open prisma/schema.prisma and replace its content with the Note model below. The @id uses cuid() to generate globally unique IDs.
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
model Note {
id String @id @default(cuid())
title String
content String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
Run the Migration
Create the initial migration and generate the Prisma Client:
npx prisma migrate dev --name init
This applies the schema to your SQLite database and generates the client in node_modules/.prisma.
Setting Up the Prisma Client Singleton
To avoid multiple PrismaClient instances during hot reloading, create a singleton module.
Create lib/prisma.ts:
import { PrismaClient } from '@prisma/client';
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
Building the API Layer with Server Actions
We’ll use Next.js Server Actions (the 'use server' directive) to handle form submissions directly. This keeps the data flow secure and avoids extra API route boilerplate.
Create app/actions.ts with validation and CRUD operations:
'use server';
import { prisma } from '@/lib/prisma';
import { revalidatePath } from 'next/cache';
import { z } from 'zod';
const NoteSchema = z.object({
title: z.string().min(1, 'Title is required').max(100),
content: z.string().min(1, 'Content is required'),
});
export async function createNote(formData: FormData) {
const { title, content } = NoteSchema.parse({
title: formData.get('title'),
content: formData.get('content'),
});
await prisma.note.create({
data: { title, content },
});
revalidatePath('/');
}
export async function updateNote(id: string, formData: FormData) {
const { title, content } = NoteSchema.parse({
title: formData.get('title'),
content: formData.get('content'),
});
await prisma.note.update({
where: { id },
data: { title, content },
});
revalidatePath('/');
}
export async function deleteNote(id: string) {
await prisma.note.delete({
where: { id },
});
revalidatePath('/');
}
What’s happening: Zod validates the incoming form data. After a mutation, revalidatePath('/') clears the client-side cache for the home page so the UI fetches fresh data.
Creating the User Interface
We’ll build three pages: a home page listing all notes, a form to create a new note, and a form to edit an existing note. Some components need to be Client Components because they use hooks (useState, useRouter).
Home Page – Listing Notes
Create or edit app/page.tsx. This is a Server Component that fetches notes directly with Prisma.
import { prisma } from '@/lib/prisma';
import Link from 'next/link';
import NoteList from './NoteList';
export default async function Home() {
const notes = await prisma.note.findMany({
orderBy: { createdAt: 'desc' },
});
return (
Notes
New Note
);
}
NoteList must be a Client Component because it uses confirm() and the deleteNote server action. Create app/NoteList.tsx:
'use client';
import { deleteNote } from '@/app/actions';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
interface Note {
id: string;
title: string;
content: string;
}
export default function NoteList({ notes }: { notes: Note[] }) {
const router = useRouter();
const [deleting, setDeleting] = useState(null);
const handleDelete = async (id: string) => {
if (!confirm('Delete this note?')) return;
setDeleting(id);
await deleteNote(id);
setDeleting(null);
router.refresh();
};
return (
{notes.map((note) => (
-
{note.title}
{note.content}
Edit
))}
);
}
Create Note Page
Create a new route app/new/page.tsx – a Client Component that calls the createNote server action on form submission.
'use client';
import { createNote } from '@/app/actions';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
export default function NewNote() {
const router = useRouter();
const [error, setError] = useState(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
const formData = new FormData(e.currentTarget);
try {
await createNote(formData);
router.push('/');
} catch (err) {
if (err instanceof Error) setError(err.message);
}
};
return (
New Note
);
}
Edit Note Page
For the edit flow, the page app/edit/[id]/page.tsx fetches the note by ID on the server, then passes it to a client form.
import { prisma } from '@/lib/prisma';
import EditNoteForm from './EditNoteForm';
export default async function EditNote({ params }: { params: { id: string } }) {
const note = await prisma.note.findUnique({
where: { id: params.id },
});
if (!note) {
return (
Note not found.
);
}
return ;
}
Now create app/edit/[id]/EditNoteForm.tsx (Client Component):
'use client';
import { updateNote } from '@/app/actions';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
interface Note {
id: string;
title: string;
content: string;
}
export default function EditNoteForm({ note }: { note: Note }) {
const router = useRouter();
const [error, setError] = useState(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
const formData = new FormData(e.currentTarget);
try {
await updateNote(note.id, formData);
router.push('/');
} catch (err) {
if (err instanceof Error) setError(err.message);
}
};
return (
Edit Note
);
}
Running the App
Start the development server:
npm run dev
Open http://localhost:3000. You can now create, view, edit, and delete notes. All data persists in the local SQLite database file prisma/dev.db.
Best Practices and Production Considerations
- Validation: Always validate user input on the server (we used Zod). Never trust raw form data.
- Error Handling: Wrap Prisma calls in try/catch blocks. Display user-friendly messages without leaking stack traces.
-
Cache Revalidation: After mutations, call
revalidatePathorrevalidateTagto keep the UI in sync. -
Environment Variables: Store
DATABASE_URLin.env. Never expose it to the browser. Add.envto.gitignore. -
Transactions: For operations that modify multiple tables, use
prisma.$transactionto ensure atomicity. -
Pagination: For a growing note collection, implement cursor-based pagination using Prisma’s
take,skip, andcursor. -
Authentication: Integrate NextAuth.js or Clerk to associate notes with authenticated users. Add a
userIdfield to the Note model. -
Production Migrations: Use
npx prisma migrate deployin CI/CD pipelines, notmigrate dev.
Conclusion
You’ve just built a full-featured note-taking app with Next.js and Prisma. By combining server components for data fetching, client components for interactivity, and server actions for mutations, you have a clean, type-safe architecture that scales well. From here you can extend the app with categories, tags, search, or user authentication. The foundation you’ve laid will serve you well in any modern full-stack project.