Build a Full-Stack App with Prisma ORM and Next.js 15 App Router

Noqta Team
By Noqta Team ·

Loading the Text to Speech Audio Player...

The most popular TypeScript ORM meets the modern React stack. Prisma gives you a declarative schema, auto-generated types, and a powerful query engine — all integrated seamlessly with Next.js 15 Server Components and Server Actions. In this tutorial you will build a complete project management app from scratch.

What You Will Learn

By the end of this tutorial, you will:

  • Set up Prisma ORM with PostgreSQL in a Next.js 15 App Router project
  • Define models and relations using Prisma Schema Language
  • Run migrations and seed the database
  • Build full CRUD operations with Next.js Server Actions
  • Use the Prisma Client for type-safe queries, filters, and pagination
  • Handle form validation with Zod and useActionState
  • Deploy to production with best practices

Prerequisites

Before starting, make sure you have:

  • Node.js 20+ installed (node --version)
  • TypeScript experience (types, interfaces, async/await)
  • Next.js familiarity (App Router, Server Components)
  • PostgreSQL running locally or a cloud instance (we will use Neon)
  • A code editor — VS Code or Cursor recommended

Why Prisma ORM?

Prisma is the most widely adopted TypeScript ORM, used by companies like Netflix, Notion, and Hashicorp. Here is what makes it stand out:

FeaturePrismaDrizzleTypeORM
SchemaDeclarative DSL (.prisma)TypeScriptDecorators
Type safetyAuto-generated typesInferred typesPartial
MigrationsBuilt-in CLIdrizzle-kitCLI or manual
RelationsFirst-class, nested writesManual joinsDecorators
Query APIIntuitive object APISQL-like APIRepository pattern
StudioBuilt-in GUI (Prisma Studio)Drizzle StudioNone
Edge supportPrisma AccelerateNativeNot optimized

Prisma takes a schema-first approach: you declare your data model in a .prisma file, and Prisma generates a fully typed client with autocomplete for every field, relation, and filter. No manual type definitions needed.


What You Will Build

A project management app with:

  • Projects that contain multiple tasks
  • Tasks with status, priority, and due dates
  • Full CRUD for both projects and tasks
  • Filtering and sorting
  • Responsive UI with Tailwind CSS

Step 1: Create a Next.js 15 Project

Scaffold a new project:

npx create-next-app@latest project-manager --typescript --tailwind --eslint --app --src-dir --import-alias "@/*"
cd project-manager

Accept the defaults. This creates a Next.js 15 project with App Router, TypeScript, and Tailwind CSS.


Step 2: Install Prisma

Install Prisma as a dev dependency and the Prisma Client:

npm install prisma --save-dev
npm install @prisma/client

Initialize Prisma with PostgreSQL as the provider:

npx prisma init --datasource-provider postgresql

This creates two files:

  • prisma/schema.prisma — your data model definition
  • .env — database connection string

Step 3: Configure the Database

Open .env and set your PostgreSQL connection string:

DATABASE_URL="postgresql://user:password@localhost:5432/project_manager?schema=public"

If you are using Neon (recommended for quick setup):

  1. Create a free account at neon.tech
  2. Create a new project
  3. Copy the connection string from the dashboard
  4. Paste it into .env

If running PostgreSQL locally with Docker:

docker run --name pg-prisma -e POSTGRES_USER=user -e POSTGRES_PASSWORD=password -e POSTGRES_DB=project_manager -p 5432:5432 -d postgres:16

Step 4: Define Your Prisma Schema

Open prisma/schema.prisma and define the data model:

generator client {
  provider = "prisma-client-js"
}
 
datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}
 
model Project {
  id          String   @id @default(cuid())
  name        String
  description String?
  color       String   @default("#3b82f6")
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt
 
  tasks Task[]
 
  @@map("projects")
}
 
model Task {
  id          String     @id @default(cuid())
  title       String
  description String?
  status      TaskStatus @default(TODO)
  priority    Priority   @default(MEDIUM)
  dueDate     DateTime?
  createdAt   DateTime   @default(now())
  updatedAt   DateTime   @updatedAt
 
  projectId String
  project   Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
 
  @@index([projectId])
  @@index([status])
  @@map("tasks")
}
 
enum TaskStatus {
  TODO
  IN_PROGRESS
  DONE
}
 
enum Priority {
  LOW
  MEDIUM
  HIGH
  URGENT
}

Key points about this schema:

  • @id @default(cuid()) generates unique IDs automatically
  • @updatedAt auto-updates the timestamp on every change
  • Task[] on Project defines a one-to-many relation
  • onDelete: Cascade deletes all tasks when a project is deleted
  • @@index creates database indexes for faster queries
  • @@map maps the model to a specific table name
  • Enums provide type-safe status and priority values

Step 5: Run Your First Migration

Generate and apply the migration:

npx prisma migrate dev --name init

This command does three things:

  1. Creates a SQL migration file in prisma/migrations/
  2. Applies the migration to your database
  3. Generates the Prisma Client with full TypeScript types

You can inspect the generated SQL in prisma/migrations/[timestamp]_init/migration.sql.


Step 6: Create the Prisma Client Singleton

In a Next.js environment, hot module reloading can create multiple Prisma Client instances. Create a singleton to prevent this.

Create src/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;
}

This ensures only one PrismaClient instance exists during development.


Step 7: Seed the Database

Create prisma/seed.ts to populate the database with sample data:

import { PrismaClient, TaskStatus, Priority } from "@prisma/client";
 
const prisma = new PrismaClient();
 
async function main() {
  // Clean existing data
  await prisma.task.deleteMany();
  await prisma.project.deleteMany();
 
  // Create projects with tasks
  const webApp = await prisma.project.create({
    data: {
      name: "Web Application Redesign",
      description: "Complete overhaul of the company website",
      color: "#3b82f6",
      tasks: {
        create: [
          {
            title: "Design new landing page",
            description: "Create wireframes and mockups for the homepage",
            status: TaskStatus.DONE,
            priority: Priority.HIGH,
            dueDate: new Date("2026-04-15"),
          },
          {
            title: "Implement authentication",
            description: "Set up login, registration, and password reset",
            status: TaskStatus.IN_PROGRESS,
            priority: Priority.URGENT,
            dueDate: new Date("2026-04-20"),
          },
          {
            title: "Build dashboard",
            description: "Create the main user dashboard with charts",
            status: TaskStatus.TODO,
            priority: Priority.MEDIUM,
            dueDate: new Date("2026-05-01"),
          },
          {
            title: "Write API documentation",
            status: TaskStatus.TODO,
            priority: Priority.LOW,
          },
        ],
      },
    },
  });
 
  const mobileApp = await prisma.project.create({
    data: {
      name: "Mobile App MVP",
      description: "React Native app for iOS and Android",
      color: "#10b981",
      tasks: {
        create: [
          {
            title: "Set up Expo project",
            status: TaskStatus.DONE,
            priority: Priority.HIGH,
          },
          {
            title: "Build navigation structure",
            status: TaskStatus.IN_PROGRESS,
            priority: Priority.HIGH,
            dueDate: new Date("2026-04-10"),
          },
          {
            title: "Integrate push notifications",
            status: TaskStatus.TODO,
            priority: Priority.MEDIUM,
            dueDate: new Date("2026-05-15"),
          },
        ],
      },
    },
  });
 
  console.log("Seeded projects:", { webApp: webApp.id, mobileApp: mobileApp.id });
}
 
main()
  .then(async () => {
    await prisma.$disconnect();
  })
  .catch(async (e) => {
    console.error(e);
    await prisma.$disconnect();
    process.exit(1);
  });

Add the seed command to package.json:

{
  "prisma": {
    "seed": "npx tsx prisma/seed.ts"
  }
}

Run the seed:

npx prisma db seed

You can verify the data using Prisma Studio:

npx prisma studio

This opens a visual database browser at http://localhost:5555.


Step 8: Build Type Definitions and Validation

Create src/lib/validations.ts for shared Zod schemas:

import { z } from "zod";
 
export const createProjectSchema = z.object({
  name: z.string().min(1, "Name is required").max(100),
  description: z.string().max(500).optional(),
  color: z.string().regex(/^#[0-9a-fA-F]{6}$/, "Invalid color format"),
});
 
export const createTaskSchema = z.object({
  title: z.string().min(1, "Title is required").max(200),
  description: z.string().max(1000).optional(),
  status: z.enum(["TODO", "IN_PROGRESS", "DONE"]).default("TODO"),
  priority: z.enum(["LOW", "MEDIUM", "HIGH", "URGENT"]).default("MEDIUM"),
  dueDate: z.string().optional(),
  projectId: z.string().min(1, "Project is required"),
});
 
export const updateTaskSchema = createTaskSchema.partial().extend({
  id: z.string().min(1),
});
 
export type CreateProjectInput = z.infer<typeof createProjectSchema>;
export type CreateTaskInput = z.infer<typeof createTaskSchema>;
export type UpdateTaskInput = z.infer<typeof updateTaskSchema>;

Step 9: Create Server Actions

Server Actions are the recommended way to handle mutations in Next.js App Router. Create src/app/actions.ts:

"use server";
 
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma";
import {
  createProjectSchema,
  createTaskSchema,
  updateTaskSchema,
} from "@/lib/validations";
 
// ─── Project Actions ─────────────────────────────────────
 
export async function createProject(formData: FormData) {
  const raw = {
    name: formData.get("name") as string,
    description: formData.get("description") as string,
    color: formData.get("color") as string,
  };
 
  const validated = createProjectSchema.safeParse(raw);
  if (!validated.success) {
    return { error: validated.error.flatten().fieldErrors };
  }
 
  await prisma.project.create({
    data: validated.data,
  });
 
  revalidatePath("/");
  return { success: true };
}
 
export async function deleteProject(id: string) {
  await prisma.project.delete({
    where: { id },
  });
 
  revalidatePath("/");
  return { success: true };
}
 
// ─── Task Actions ─────────────────────────────────────────
 
export async function createTask(formData: FormData) {
  const raw = {
    title: formData.get("title") as string,
    description: formData.get("description") as string,
    status: formData.get("status") as string,
    priority: formData.get("priority") as string,
    dueDate: formData.get("dueDate") as string,
    projectId: formData.get("projectId") as string,
  };
 
  const validated = createTaskSchema.safeParse(raw);
  if (!validated.success) {
    return { error: validated.error.flatten().fieldErrors };
  }
 
  const { dueDate, ...rest } = validated.data;
 
  await prisma.task.create({
    data: {
      ...rest,
      dueDate: dueDate ? new Date(dueDate) : null,
    },
  });
 
  revalidatePath("/");
  return { success: true };
}
 
export async function updateTaskStatus(id: string, status: string) {
  const validated = updateTaskSchema.safeParse({ id, status });
  if (!validated.success) {
    return { error: "Invalid input" };
  }
 
  await prisma.task.update({
    where: { id },
    data: { status: validated.data.status },
  });
 
  revalidatePath("/");
  return { success: true };
}
 
export async function deleteTask(id: string) {
  await prisma.task.delete({
    where: { id },
  });
 
  revalidatePath("/");
  return { success: true };
}

Key patterns here:

  • "use server" marks the file as containing Server Actions
  • Zod validation ensures data integrity before any database operation
  • revalidatePath("/") clears the cache so the UI updates immediately
  • Typed return values let the client handle success and error states

Step 10: Build the Project List Page

Create the main page at src/app/page.tsx:

import Link from "next/link";
import { prisma } from "@/lib/prisma";
import { deleteProject } from "./actions";
 
export default async function HomePage() {
  const projects = await prisma.project.findMany({
    include: {
      _count: {
        select: { tasks: true },
      },
      tasks: {
        select: { status: true },
      },
    },
    orderBy: { createdAt: "desc" },
  });
 
  return (
    <main className="max-w-4xl mx-auto p-8">
      <div className="flex items-center justify-between mb-8">
        <h1 className="text-3xl font-bold">Projects</h1>
        <Link
          href="/projects/new"
          className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700"
        >
          New Project
        </Link>
      </div>
 
      <div className="grid gap-4">
        {projects.map((project) => {
          const done = project.tasks.filter((t) => t.status === "DONE").length;
          const total = project._count.tasks;
          const progress = total > 0 ? Math.round((done / total) * 100) : 0;
 
          return (
            <Link
              key={project.id}
              href={`/projects/${project.id}`}
              className="block border rounded-lg p-6 hover:shadow-md transition-shadow"
            >
              <div className="flex items-start justify-between">
                <div className="flex items-center gap-3">
                  <div
                    className="w-4 h-4 rounded-full"
                    style={{ backgroundColor: project.color }}
                  />
                  <div>
                    <h2 className="text-xl font-semibold">{project.name}</h2>
                    {project.description && (
                      <p className="text-gray-500 mt-1">
                        {project.description}
                      </p>
                    )}
                  </div>
                </div>
                <span className="text-sm text-gray-400">
                  {total} task{total !== 1 ? "s" : ""}
                </span>
              </div>
 
              {total > 0 && (
                <div className="mt-4">
                  <div className="flex justify-between text-sm mb-1">
                    <span className="text-gray-500">Progress</span>
                    <span className="font-medium">{progress}%</span>
                  </div>
                  <div className="w-full bg-gray-200 rounded-full h-2">
                    <div
                      className="h-2 rounded-full transition-all"
                      style={{
                        width: `${progress}%`,
                        backgroundColor: project.color,
                      }}
                    />
                  </div>
                </div>
              )}
            </Link>
          );
        })}
      </div>
 
      {projects.length === 0 && (
        <div className="text-center py-12 text-gray-500">
          <p className="text-lg">No projects yet</p>
          <p className="mt-2">Create your first project to get started.</p>
        </div>
      )}
    </main>
  );
}

Notice how Prisma queries run directly inside the Server Component — no API routes needed. The include option lets you fetch related data and aggregates in a single query.


Step 11: Build the Project Detail Page

Create src/app/projects/[id]/page.tsx:

import { notFound } from "next/navigation";
import { prisma } from "@/lib/prisma";
import { TaskList } from "./task-list";
import { TaskForm } from "./task-form";
 
interface Props {
  params: Promise<{ id: string }>;
}
 
export default async function ProjectPage({ params }: Props) {
  const { id } = await params;
 
  const project = await prisma.project.findUnique({
    where: { id },
    include: {
      tasks: {
        orderBy: [
          { status: "asc" },
          { priority: "desc" },
          { createdAt: "desc" },
        ],
      },
    },
  });
 
  if (!project) {
    notFound();
  }
 
  const tasksByStatus = {
    TODO: project.tasks.filter((t) => t.status === "TODO"),
    IN_PROGRESS: project.tasks.filter((t) => t.status === "IN_PROGRESS"),
    DONE: project.tasks.filter((t) => t.status === "DONE"),
  };
 
  return (
    <main className="max-w-5xl mx-auto p-8">
      <div className="mb-8">
        <div className="flex items-center gap-3 mb-2">
          <div
            className="w-5 h-5 rounded-full"
            style={{ backgroundColor: project.color }}
          />
          <h1 className="text-3xl font-bold">{project.name}</h1>
        </div>
        {project.description && (
          <p className="text-gray-500 text-lg">{project.description}</p>
        )}
      </div>
 
      <TaskForm projectId={project.id} />
 
      <div className="grid md:grid-cols-3 gap-6 mt-8">
        <TaskColumn title="To Do" tasks={tasksByStatus.TODO} color="#6b7280" />
        <TaskColumn
          title="In Progress"
          tasks={tasksByStatus.IN_PROGRESS}
          color="#f59e0b"
        />
        <TaskColumn title="Done" tasks={tasksByStatus.DONE} color="#10b981" />
      </div>
    </main>
  );
}
 
function TaskColumn({
  title,
  tasks,
  color,
}: {
  title: string;
  tasks: any[];
  color: string;
}) {
  return (
    <div>
      <div className="flex items-center gap-2 mb-4">
        <div
          className="w-3 h-3 rounded-full"
          style={{ backgroundColor: color }}
        />
        <h2 className="font-semibold text-lg">{title}</h2>
        <span className="text-sm text-gray-400 ml-auto">{tasks.length}</span>
      </div>
      <TaskList tasks={tasks} />
    </div>
  );
}

Step 12: Build the Task Components

Create the task list client component at src/app/projects/[id]/task-list.tsx:

"use client";
 
import { updateTaskStatus, deleteTask } from "@/app/actions";
import { useTransition } from "react";
 
interface Task {
  id: string;
  title: string;
  description: string | null;
  status: string;
  priority: string;
  dueDate: string | null;
}
 
const priorityColors: Record<string, string> = {
  LOW: "bg-gray-100 text-gray-600",
  MEDIUM: "bg-blue-100 text-blue-600",
  HIGH: "bg-orange-100 text-orange-600",
  URGENT: "bg-red-100 text-red-600",
};
 
export function TaskList({ tasks }: { tasks: Task[] }) {
  return (
    <div className="space-y-3">
      {tasks.map((task) => (
        <TaskCard key={task.id} task={task} />
      ))}
      {tasks.length === 0 && (
        <p className="text-gray-400 text-sm text-center py-4">No tasks</p>
      )}
    </div>
  );
}
 
function TaskCard({ task }: { task: Task }) {
  const [isPending, startTransition] = useTransition();
 
  const handleStatusChange = (newStatus: string) => {
    startTransition(() => {
      updateTaskStatus(task.id, newStatus);
    });
  };
 
  const handleDelete = () => {
    startTransition(() => {
      deleteTask(task.id);
    });
  };
 
  return (
    <div
      className={`border rounded-lg p-4 bg-white ${
        isPending ? "opacity-50" : ""
      }`}
    >
      <div className="flex items-start justify-between">
        <h3 className="font-medium">{task.title}</h3>
        <button
          onClick={handleDelete}
          className="text-gray-400 hover:text-red-500 text-sm"
        >
          &times;
        </button>
      </div>
 
      {task.description && (
        <p className="text-gray-500 text-sm mt-1">{task.description}</p>
      )}
 
      <div className="flex items-center gap-2 mt-3">
        <span
          className={`text-xs px-2 py-1 rounded-full ${
            priorityColors[task.priority]
          }`}
        >
          {task.priority}
        </span>
 
        {task.dueDate && (
          <span className="text-xs text-gray-400">
            Due {new Date(task.dueDate).toLocaleDateString()}
          </span>
        )}
      </div>
 
      <div className="flex gap-1 mt-3">
        {["TODO", "IN_PROGRESS", "DONE"].map((status) => (
          <button
            key={status}
            onClick={() => handleStatusChange(status)}
            disabled={task.status === status}
            className={`text-xs px-2 py-1 rounded ${
              task.status === status
                ? "bg-gray-900 text-white"
                : "bg-gray-100 hover:bg-gray-200 text-gray-600"
            }`}
          >
            {status.replace("_", " ")}
          </button>
        ))}
      </div>
    </div>
  );
}

Create the task form at src/app/projects/[id]/task-form.tsx:

"use client";
 
import { createTask } from "@/app/actions";
import { useRef } from "react";
import { useActionState } from "react";
 
const initialState = { error: null as any, success: false };
 
export function TaskForm({ projectId }: { projectId: string }) {
  const formRef = useRef<HTMLFormElement>(null);
 
  async function action(_prev: typeof initialState, formData: FormData) {
    formData.set("projectId", projectId);
    const result = await createTask(formData);
    if (result.success) {
      formRef.current?.reset();
    }
    return result as typeof initialState;
  }
 
  const [state, formAction, isPending] = useActionState(action, initialState);
 
  return (
    <form ref={formRef} action={formAction} className="border rounded-lg p-4">
      <h3 className="font-semibold mb-3">Add New Task</h3>
 
      <div className="grid md:grid-cols-2 gap-3">
        <input
          name="title"
          placeholder="Task title"
          required
          className="border rounded px-3 py-2"
        />
 
        <select name="priority" className="border rounded px-3 py-2">
          <option value="LOW">Low Priority</option>
          <option value="MEDIUM" selected>
            Medium Priority
          </option>
          <option value="HIGH">High Priority</option>
          <option value="URGENT">Urgent</option>
        </select>
 
        <input
          name="description"
          placeholder="Description (optional)"
          className="border rounded px-3 py-2"
        />
 
        <input
          name="dueDate"
          type="date"
          className="border rounded px-3 py-2"
        />
      </div>
 
      <input type="hidden" name="status" value="TODO" />
 
      {state.error && (
        <p className="text-red-500 text-sm mt-2">
          {Object.values(state.error).flat().join(", ")}
        </p>
      )}
 
      <button
        type="submit"
        disabled={isPending}
        className="mt-3 bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 disabled:opacity-50"
      >
        {isPending ? "Adding..." : "Add Task"}
      </button>
    </form>
  );
}

Step 13: Advanced Prisma Queries

Prisma excels at complex queries. Here are patterns you will use frequently:

Filtering and Pagination

// Get tasks with filters
const tasks = await prisma.task.findMany({
  where: {
    projectId: "some-id",
    status: "IN_PROGRESS",
    priority: { in: ["HIGH", "URGENT"] },
    dueDate: { lte: new Date() }, // overdue tasks
  },
  orderBy: { priority: "desc" },
  skip: 0,
  take: 20,
});

Aggregations

// Count tasks by status per project
const stats = await prisma.task.groupBy({
  by: ["projectId", "status"],
  _count: { id: true },
});

Nested Writes (Transactions)

// Create a project with tasks in a single transaction
const project = await prisma.project.create({
  data: {
    name: "New Project",
    tasks: {
      createMany: {
        data: [
          { title: "Task 1", priority: "HIGH" },
          { title: "Task 2", priority: "MEDIUM" },
        ],
      },
    },
  },
  include: { tasks: true },
});

Interactive Transactions

// Transfer all tasks from one project to another
await prisma.$transaction(async (tx) => {
  const tasks = await tx.task.findMany({
    where: { projectId: sourceId },
  });
 
  await tx.task.updateMany({
    where: { projectId: sourceId },
    data: { projectId: targetId },
  });
 
  await tx.project.delete({
    where: { id: sourceId },
  });
 
  return tasks.length;
});

Step 14: Add Search and Filtering

Create a search component at src/app/search.tsx:

"use client";
 
import { useRouter, useSearchParams } from "next/navigation";
import { useCallback } from "react";
 
export function SearchBar() {
  const router = useRouter();
  const searchParams = useSearchParams();
 
  const updateFilter = useCallback(
    (key: string, value: string) => {
      const params = new URLSearchParams(searchParams.toString());
      if (value) {
        params.set(key, value);
      } else {
        params.delete(key);
      }
      router.push(`/?${params.toString()}`);
    },
    [router, searchParams]
  );
 
  return (
    <div className="flex gap-3 mb-6">
      <input
        type="search"
        placeholder="Search projects..."
        defaultValue={searchParams.get("q") ?? ""}
        onChange={(e) => updateFilter("q", e.target.value)}
        className="border rounded px-3 py-2 flex-1"
      />
      <select
        defaultValue={searchParams.get("sort") ?? "newest"}
        onChange={(e) => updateFilter("sort", e.target.value)}
        className="border rounded px-3 py-2"
      >
        <option value="newest">Newest First</option>
        <option value="oldest">Oldest First</option>
        <option value="name">By Name</option>
      </select>
    </div>
  );
}

Update the home page to use search params:

interface Props {
  searchParams: Promise<{ q?: string; sort?: string }>;
}
 
export default async function HomePage({ searchParams }: Props) {
  const { q, sort } = await searchParams;
 
  const projects = await prisma.project.findMany({
    where: q
      ? {
          OR: [
            { name: { contains: q, mode: "insensitive" } },
            { description: { contains: q, mode: "insensitive" } },
          ],
        }
      : undefined,
    include: {
      _count: { select: { tasks: true } },
      tasks: { select: { status: true } },
    },
    orderBy:
      sort === "name"
        ? { name: "asc" }
        : sort === "oldest"
        ? { createdAt: "asc" }
        : { createdAt: "desc" },
  });
 
  // ... rest of the component
}

Step 15: Production Best Practices

Connection Pooling

For serverless environments, configure connection pooling in your schema:

datasource db {
  provider  = "postgresql"
  url       = env("DATABASE_URL")
  directUrl = env("DIRECT_URL")
}

Set DATABASE_URL to a pooled connection string (PgBouncer, Neon pooler, or Prisma Accelerate) and DIRECT_URL to the direct connection (used only for migrations).

Prisma Accelerate

For edge deployments, enable Prisma Accelerate:

npm install @prisma/extension-accelerate

Update the generator:

generator client {
  provider = "prisma-client-js"
}

And your client:

import { PrismaClient } from "@prisma/client";
import { withAccelerate } from "@prisma/extension-accelerate";
 
export const prisma = new PrismaClient().$extends(withAccelerate());

This enables global caching and connection pooling at the edge.

Logging

Enable query logging in development:

export const prisma = new PrismaClient({
  log:
    process.env.NODE_ENV === "development"
      ? ["query", "error", "warn"]
      : ["error"],
});

Troubleshooting

"PrismaClientInitializationError: Can't reach database server"

  • Check your DATABASE_URL in .env
  • Ensure PostgreSQL is running
  • Verify network access (firewall, VPN)

Types not updating after schema changes

Run the client generation manually:

npx prisma generate

This regenerates the TypeScript types from your schema.

"Unique constraint violation"

Your data has duplicate values in a unique field. Either update the conflicting record or use upsert:

await prisma.project.upsert({
  where: { id: "existing-id" },
  update: { name: "Updated Name" },
  create: { name: "New Project" },
});

Migration drift in production

If your database schema has drifted from the migrations:

npx prisma migrate resolve --applied "migration_name"

Or reset (development only):

npx prisma migrate reset

Next Steps

Now that you have a working full-stack app with Prisma and Next.js, explore these topics:

  • Add authentication with NextAuth.js or Better Auth to scope projects per user
  • Implement real-time updates with Prisma Pulse for live task boards
  • Add file uploads for task attachments using Vercel Blob or S3
  • Set up CI/CD with GitHub Actions to run migrations automatically
  • Explore Prisma Client extensions for middleware, soft deletes, and audit logging

Conclusion

In this tutorial, you built a complete project management application using Prisma ORM and Next.js 15 App Router. You learned how to:

  1. Define a data model with Prisma Schema Language including relations and enums
  2. Run migrations and seed the database
  3. Create a singleton Prisma Client for Next.js
  4. Build type-safe Server Actions for all CRUD operations
  5. Use Prisma's query API for filtering, pagination, and aggregations
  6. Handle nested writes and transactions for complex operations
  7. Implement search and filtering with URL search params
  8. Configure production deployment with connection pooling and edge support

Prisma's declarative schema and auto-generated types eliminate an entire category of bugs — you cannot query a field that does not exist, pass the wrong type, or forget a required relation. Combined with Next.js Server Components and Server Actions, you get a full-stack development experience that is both productive and type-safe from database to UI.


Want to read more tutorials? Check out our latest tutorial on Explore Improved Image and Video Segmentation with SAM 2 for Accurate Context-Aware Results.

Discuss Your Project with Us

We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.

Let's find the best solutions for your needs.

Related Articles