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
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
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.
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.
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
TaskListthat calls it and removes the task from local state. - Implement filtering and sorting — Filter by priority, by completion status, by user. The Prisma
whereclause already supports all of this; you just need the UI controls. - Switch to PostgreSQL for production — Change the
providerin your schema, update theDATABASE_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.tsxfiles 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.tsxfiles 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.