Building a Full-Stack App with Next.js 15 and Prisma

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.

ADVERTISEMENT
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.

ADVERTISEMENT

Leave a Comment

Your email address will not be published. Required fields are marked with an asterisk.