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

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

What Would It Take to Go from Zero to Deployed App in One Afternoon?

Seriously — picture it. You sit down after lunch with nothing but a terminal and an idea. By dinner, you’ve got a working full-stack application with a real database, proper API routes, type safety from top to bottom, and a UI that actually does something. No separate backend project. No REST boilerplate you copied from three different Stack Overflow answers. Just one codebase, one language, one deploy.

Sounds ambitious, maybe even a little reckless. But that’s exactly what we’re building today.

Back in early 2024, I tried this experiment myself during a long weekend in Pune. I’d been hearing about Next.js 15’s App Router and how Prisma had gotten faster, and I figured: why not stress-test both by building something real? Not a to-do app tutorial where you never touch a database. A proper task manager with users, relationships, validation, and CRUD operations — the kind of thing you’d actually put on a portfolio.

What follows is that journey, reconstructed and refined. We’ll use Next.js 15, Prisma ORM, and TypeScript to build a full-stack task management app from scratch. Every code block here runs. Every step builds on the last. And by the end, you’ll have something you can extend, deploy, and genuinely use.

Fair warning: there’s probably more ground to cover than you’d expect. But that’s sort of the point.

Chapter 1: Laying the Foundation

Every project starts with a blank directory and a single command. Next.js 15 ships with the App Router enabled by default now — no more opting in, no more choosing between pages and app. Server Components, nested layouts, built-in data fetching patterns: all of it’s there from the first npx call.

Fire up your terminal and let’s get moving.

npx create-next-app@latest task-manager --typescript --tailwind --eslint --app --src-dir
cd task-manager

That gives you a TypeScript project with Tailwind CSS, ESLint, and the src/ directory structure. I’ve found this setup hits the sweet spot between having enough tooling to be productive and not drowning in config files before you’ve written a single line of actual code.

Next up: Prisma. You’ll need two packages — the CLI for migrations and schema management, and the client library for type-safe database access.

npm install prisma --save-dev
npm install @prisma/client
npx prisma init --datasource-provider sqlite
Tip: We’re using SQLite here because it’s zero-config and lives as a single file in your project. But Prisma supports PostgreSQL, MySQL, and MongoDB too — switching later requires changing just one line in your schema. Don’t overthink the database choice for development.

Running prisma init creates two things: a prisma/schema.prisma file (where your data models live) and a .env file with your database connection URL. SQLite’s URL will point to a local file. Simple.

At this point, your project directory should feel clean but purposeful. Nothing extra, nothing missing. That’s the vibe we want.

Chapter 2: Designing the Data Layer

Here’s where things get interesting. Prisma uses a declarative schema language — you describe what your data looks like, and it generates the TypeScript types, the migration SQL, and the query client for you. No writing raw CREATE TABLE statements. No manually syncing interfaces with your database structure.

For our task manager, we need two models: User and Task. A user can have many tasks. A task belongs to one user. Classic one-to-many relationship.

Open prisma/schema.prisma and replace whatever’s there with this:

// 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
}

Notice a few things. The ? after String on description makes it optional. The @default(false) on completed means new tasks start unchecked. And onDelete: Cascade means if you delete a user, all their tasks vanish too — no orphaned rows cluttering your database.

I’ll admit: the first time I saw Prisma’s schema language, I wasn’t sure it was better than just writing SQL. But after watching it auto-generate perfectly typed queries? Yeah, I came around pretty quick.

Now run the migration and generate the client:

npx prisma migrate dev --name init
npx prisma generate

Two commands. Your database tables now exist. Your TypeScript types are generated. You can import { User, Task } from Prisma’s client and get full autocomplete in your editor.

One more piece of plumbing before we move on. In development, Next.js hot-reloads your code constantly. Every reload would normally create a new Prisma client instance, which means a new database connection. Do that enough times and you’ll exhaust your connection pool. The fix is a singleton pattern:

// 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
Why this matters: Without this singleton, you might see “Too many database connections” errors during development after a few hot reloads. In production, a single new PrismaClient() works fine since the server doesn’t hot-reload. But for dev, this pattern saves you real headaches.

Chapter 3: Building the API — Your App’s Backbone

With the data layer ready, we need endpoints. Next.js 15’s App Router handles this through route.ts files — you export named functions for each HTTP method, and the framework routes requests to them automatically. No Express. No separate server process. Everything lives inside your Next.js project.

Let’s start with the main tasks endpoint. Creating and listing tasks covers probably 60% of what any CRUD app does, so getting these right matters.

// 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 })
}

See how Prisma’s include works? When we fetch tasks, we also pull in the user’s name and email — joined automatically, no SQL joins to write. And the where clause conditionally filters by user ID if the query param exists. Clean, readable, type-safe.

But we’re not done yet. Listing and creating tasks is half the story. We also need to update and delete individual tasks, which requires a dynamic route segment — a folder named [id] that captures the task ID from the URL.

// 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' })
}

One thing I want to point out: in Next.js 15, params is now a Promise. Earlier versions passed it as a plain object. If you’re coming from Next.js 13 or 14 and your dynamic routes suddenly break, that’s almost certainly why. You need to await params before destructuring.

At this point, you could open a tool like Postman or curl and test all four operations. Create a user first (we’ll add that endpoint in a minute, or seed the database directly with Prisma Studio), then create tasks, list them, update one, delete another. It should all work.

Quick test shortcut: Run npx prisma studio to open a visual database browser. You can manually add a test user there, then use curl or your browser’s dev console to hit your API endpoints. Way faster than building a form just to test your backend.

Chapter 4: Server Components Change Everything

Here’s the part that genuinely surprised me when I first tried it. With Server Components in Next.js 15, you can query your database directly inside a React component. Not through an API call. Not through a fetch wrapper. Directly. The component runs on the server, grabs the data, renders the HTML, and sends it to the browser. Zero client-side JavaScript for the data fetching itself.

Let me show you what I mean.

// 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>
  )
}

Look at that. An async function component that calls prisma.task.findMany and passes the results straight to a child component. No useEffect. No loading states for the initial render. No “fetching…” spinner. When the page loads in the browser, the data is already there because the server already rendered it.

But wait — if the server rendered everything, how do users interact with the tasks? Toggle a checkbox, delete something, reorder by priority? That’s where the client component boundary comes in.

Our TaskList component needs interactivity, so it gets the 'use client' directive. It receives the server-fetched data as initialTasks, then manages state and handles user interactions from there.

// 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>
  )
}

Notice the pattern: server component does the heavy lifting (database query, initial render), client component handles the light stuff (toggling checkboxes, optimistic UI updates). The first page load is fast because no JavaScript needs to execute before you see content. Interactions after that are snappy because the client component only re-renders what changed.

I think this is probably the single biggest mental shift for React developers moving to Next.js 15. You’re not building a client-side app that happens to have a server. You’re building a server-rendered app that selectively hydrates interactive pieces. Once that clicks, the architecture feels almost obvious.

Chapter 5: Validation — Because Users Will Break Everything

Here’s a lesson I learned the hard way during that Pune weekend. My task manager was working beautifully — until I accidentally submitted an empty title through a curl command and created a ghost task with no name. The database accepted it without complaint. Prisma didn’t care. My UI rendered a blank checkbox with nothing next to it.

Production applications need validation. Not “it would be nice to have” validation. Real, runtime, reject-bad-data-before-it-touches-your-database validation.

Zod handles this for TypeScript apps. It lets you define validation schemas that look a lot like TypeScript types, except they actually run at runtime and catch problems.

npm install zod

Create a validation file:

// 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>

See that last line? z.infer extracts a TypeScript type from the Zod schema, so your validation rules and your types stay in sync automatically. Change the schema, the type changes. No drift. No forgetting to update one when you update the other.

Now wrap your POST handler with this validation:

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 })
}

With safeParse, Zod won’t throw an exception on bad input — it returns a result object you can check. If validation fails, you get structured error messages per field. If it passes, result.data is fully typed and safe to hand to Prisma.

The type safety chain: Prisma generates types from your database schema. Zod validates incoming data at runtime. TypeScript catches errors at compile time. All three layers work together, and they catch different kinds of problems. Prisma guards the shape of your data. Zod guards the quality. TypeScript guards your logic. Skip any one of them and you’ve got a gap.

Could you skip Zod and just check fields manually with if statements? Sure. I’ve done it. Everyone has. But the moment you need to validate nested objects, optional fields with defaults, or enums with specific allowed values, those manual checks turn into spaghetti. Zod keeps them structured from day one.

Chapter 6: Stepping Back — What We Actually Built

Let’s take a breath and look at what’s in front of us. In roughly an afternoon’s worth of work, we’ve assembled:

  • A Next.js 15 project with the App Router, TypeScript, and Tailwind CSS
  • A Prisma data layer with two related models, auto-generated types, and migration history
  • Full CRUD API routes that create, read, update, and delete tasks
  • Server Components that query the database at render time with zero client-side fetch overhead
  • A client component that handles interactive features like toggling task completion
  • Zod validation that catches bad data before it reaches the database
  • End-to-end type safety from database schema to API to UI

Not bad for one sitting. Honestly, the hardest part wasn’t any individual piece — it was seeing how they fit together. Next.js provides the framework. Prisma provides the data access. Zod provides the guardrails. TypeScript ties the whole thing together with compile-time guarantees. Each tool does one thing well, and they compose without fighting each other.

A few things I noticed during the build that might save you some trouble:

The params change in Next.js 15 is subtle but breaking. If your dynamic routes work in 14 but fail in 15, check whether you’re awaiting params. I burned about forty minutes on this before I found the migration note buried in the docs.

Prisma Studio is underrated. Running npx prisma studio opens a web GUI for your database. During development, I used it constantly to check whether my API routes were actually writing the right data. Way faster than writing test queries.

Server Components aren’t a silver bullet. They’re fantastic for data-heavy pages where you want fast initial loads. But the moment you need useState, useEffect, or any browser API, you need a client component. The art is knowing where to draw the boundary. My rule of thumb: fetch on the server, interact on the client.

What to Build Next: Your Checklist

We’ve got a working full-stack app, but there’s always more. Here’s a concrete list of what I’d tackle next, roughly in priority order. Each item builds on what we’ve already done, and none of them require ripping anything apart.

  • Add user authentication — NextAuth.js (now Auth.js) integrates cleanly with Next.js 15 and Prisma. Protect your API routes so random people can’t create tasks for other users.
  • Build a task creation form — We built the POST endpoint but no UI for it yet. A client component with a form, optimistic updates, and Zod validation on the client side would round this out.
  • Add delete functionality to the UI — The DELETE endpoint exists. Wire up a button in TaskList that calls it and removes the task from local state.
  • Implement filtering and sorting — Filter by priority, by completion status, by user. The Prisma where clause already supports all of this; you just need the UI controls.
  • Switch to PostgreSQL for production — Change the provider in your schema, update the DATABASE_URL, run a fresh migration. Five-minute swap, but it unlocks features like full-text search and concurrent connections.
  • Add error boundaries — Next.js 15 supports error.tsx files in any route segment. Catch database failures, network errors, and unexpected exceptions gracefully instead of showing a blank page.
  • Write API tests — Use Vitest or Jest to test your route handlers. Mock Prisma’s client, send fake requests, assert on responses. Catches regressions before they hit users.
  • Deploy to Vercel — Push to GitHub, connect Vercel, set your environment variables, and you’re live. Next.js on Vercel is probably the smoothest deploy experience in the JavaScript ecosystem right now.
  • Add loading and streaming UI — Create loading.tsx files for instant loading states. Use React Suspense boundaries for granular streaming. Makes the app feel faster even when the database is slow.
  • Implement Server Actions — Next.js 15 supports Server Actions for form mutations without building API routes. Could replace or complement the REST endpoints we built. Worth exploring once you’re comfortable with the current architecture.

Pick the first two or three and build them this week. Don’t try to tackle all ten at once — you’ll end up with ten half-finished features and nothing to show. Better to have a deployed app with auth and a working form than a local project with everything scaffolded and nothing complete.

What surprised me most about this whole build? How little friction there was. A year ago, “full-stack TypeScript” meant stitching together five or six tools that barely talked to each other. In mid-2025, Next.js 15 and Prisma genuinely feel like a single system. The types flow from database to UI without a single manual cast. The server/client boundary is explicit but not painful. And the whole thing fits in one repo.

Go build something. Start messy. Ship it before it’s perfect. You can always refactor — and honestly, you probably will.

Leave a Comment

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