Building modern full-stack applications no longer requires juggling separate frontend and backend projects. Next.js 15, combined with Prisma ORM, gives you a single codebase where your React components and database queries live side by side. This guide walks you through building a complete task management app from scratch, covering project setup, schema design, API routes, and CRUD operations — all in TypeScript.
Project Setup and Initial Configuration
Start by creating a fresh Next.js 15 project with the App Router enabled. The App Router is now the default, offering server components, nested layouts, and built-in data fetching patterns that pair perfectly with Prisma.
npx create-next-app@latest task-manager --typescript --tailwind --eslint --app --src-dir
cd task-manager
Next, install Prisma and its client library. The Prisma CLI handles migrations and schema management, while the client provides type-safe database access.
npm install prisma --save-dev
npm install @prisma/client
npx prisma init --datasource-provider sqlite
We are using SQLite for development simplicity, but Prisma supports PostgreSQL, MySQL, and MongoDB with a single schema change. The prisma init command creates a prisma/schema.prisma file and a .env file with your database URL.
Designing the Database Schema
Prisma uses a declarative schema language to define your data models. Each model maps directly to a database table, and Prisma generates TypeScript types automatically. Here is a schema for our task manager with users and tasks.
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model User {
id Int @id @default(autoincrement())
email String @unique
name String
tasks Task[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Task {
id Int @id @default(autoincrement())
title String
description String?
completed Boolean @default(false)
priority String @default("medium")
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
The schema defines a one-to-many relationship between User and Task. The onDelete: Cascade ensures that deleting a user removes their tasks. Run the migration to create your database tables.
npx prisma migrate dev --name init
npx prisma generate
Create a reusable Prisma client instance to avoid creating multiple connections during development hot reloads.
// 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
Building API Routes for CRUD Operations
Next.js 15 App Router uses route.ts files to define API endpoints. Each HTTP method is exported as a named function. Here is the complete tasks API with create, read, update, and delete operations.
// src/app/api/tasks/route.ts
import { prisma } from '@/lib/prisma'
import { NextRequest, NextResponse } from 'next/server'
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams
const userId = searchParams.get('userId')
const tasks = await prisma.task.findMany({
where: userId ? { userId: parseInt(userId) } : undefined,
include: { user: { select: { name: true, email: true } } },
orderBy: { createdAt: 'desc' },
})
return NextResponse.json(tasks)
}
export async function POST(request: NextRequest) {
const body = await request.json()
const { title, description, priority, userId } = body
if (!title || !userId) {
return NextResponse.json(
{ error: 'Title and userId are required' },
{ status: 400 }
)
}
const task = await prisma.task.create({
data: { title, description, priority, userId },
include: { user: { select: { name: true } } },
})
return NextResponse.json(task, { status: 201 })
}
For individual task operations, create a dynamic route segment that captures the task ID.
// src/app/api/tasks/[id]/route.ts
import { prisma } from '@/lib/prisma'
import { NextRequest, NextResponse } from 'next/server'
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const body = await request.json()
const task = await prisma.task.update({
where: { id: parseInt(id) },
data: {
title: body.title,
description: body.description,
completed: body.completed,
priority: body.priority,
},
})
return NextResponse.json(task)
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
await prisma.task.delete({
where: { id: parseInt(id) },
})
return NextResponse.json({ message: 'Task deleted' })
}
Server Components and Data Fetching
One of the most powerful features of Next.js 15 is server components. You can query Prisma directly inside your components without building separate API endpoints for page rendering.
// src/app/tasks/page.tsx
import { prisma } from '@/lib/prisma'
import TaskList from '@/components/TaskList'
export default async function TasksPage() {
const tasks = await prisma.task.findMany({
include: { user: { select: { name: true } } },
orderBy: { createdAt: 'desc' },
})
return (
<main className="max-w-4xl mx-auto p-6">
<h1 className="text-3xl font-bold mb-6">Task Manager</h1>
<TaskList initialTasks={tasks} />
</main>
)
}
The server component fetches data at render time with zero client-side JavaScript for the query itself. The TaskList component receives the data as props and handles interactive features like toggling completion status or deleting tasks on the client side.
// src/components/TaskList.tsx
'use client'
import { useState } from 'react'
interface Task {
id: number
title: string
completed: boolean
priority: string
user: { name: string }
}
export default function TaskList({ initialTasks }: { initialTasks: Task[] }) {
const [tasks, setTasks] = useState(initialTasks)
async function toggleComplete(id: number, completed: boolean) {
await fetch(`/api/tasks/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ completed: !completed }),
})
setTasks(tasks.map(t => t.id === id ? { ...t, completed: !completed } : t))
}
return (
<ul className="space-y-3">
{tasks.map(task => (
<li key={task.id} className="flex items-center gap-3 p-4 border rounded-lg">
<input
type="checkbox"
checked={task.completed}
onChange={() => toggleComplete(task.id, task.completed)}
/>
<span className={task.completed ? 'line-through text-gray-400' : ''}>
{task.title}
</span>
<span className="ml-auto text-sm text-gray-500">{task.user.name}</span>
</li>
))}
</ul>
)
}
Input Validation and Error Handling
Production applications need proper validation. Install Zod for runtime type checking that works seamlessly with TypeScript.
npm install zod
Create a validation schema and use it in your API routes to reject malformed requests before they reach the database.
// src/lib/validations.ts
import { z } from 'zod'
export const createTaskSchema = z.object({
title: z.string().min(1, 'Title is required').max(200),
description: z.string().max(1000).optional(),
priority: z.enum(['low', 'medium', 'high']).default('medium'),
userId: z.number().int().positive(),
})
export type CreateTaskInput = z.infer<typeof createTaskSchema>
Then wrap your POST handler with validation logic that returns structured error messages.
export async function POST(request: NextRequest) {
const body = await request.json()
const result = createTaskSchema.safeParse(body)
if (!result.success) {
return NextResponse.json(
{ errors: result.error.flatten().fieldErrors },
{ status: 400 }
)
}
const task = await prisma.task.create({
data: result.data,
})
return NextResponse.json(task, { status: 201 })
}
This approach gives you end-to-end type safety: Prisma generates types from your database schema, Zod validates incoming data at runtime, and TypeScript catches errors at compile time. Together, Next.js 15 and Prisma deliver a full-stack development experience that is fast to build and reliable in production.