← Back to DevBytes

How to Build a Note-Taking App with Next.js and Prisma

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