Next.js 14 Complete Tutorial: Build a Full-Stack App Step by Step
A hands-on tutorial to build a complete task manager app using Next.js 14 App Router, Server Components, Server Actions, Prisma, and TypeScript.
What We Are Building
We are going to build a full-stack task manager application from scratch. Not a toy demo — a real, functional app with authentication, a database, server-side rendering, and deployment. By the end, you will have a working app deployed on Vercel that you can actually use daily.
The tech stack:
- Next.js 14 with the App Router
- TypeScript for type safety
- Prisma with SQLite for the database
- Server Components for fast page loads
- Server Actions for mutations
- Middleware for authentication
- Tailwind CSS for styling
I chose this stack because it represents how modern full-stack React apps are built in production. Server Components and Server Actions are not experimental anymore — they are the default in Next.js 14, and understanding them is essential for any React developer moving forward.
Setting Up the Project
Open your terminal and create a new Next.js project:
npx create-next-app@14 task-manager --typescript --tailwind --eslint --app --src-dir
cd task-manager
When prompted, accept the default options. This gives us a project with TypeScript, Tailwind CSS, ESLint, the App Router, and a src directory structure.
Now install Prisma and a few utilities:
npm install prisma @prisma/client bcryptjs jsonwebtoken
npm install -D @types/bcryptjs @types/jsonwebtoken
Your project structure should look like this:
task-manager/
├── src/
│ ├── app/
│ │ ├── layout.tsx
│ │ ├── page.tsx
│ │ └── globals.css
│ └── ...
├── prisma/
├── public/
├── package.json
├── tsconfig.json
└── tailwind.config.ts
Understanding the App Router
Before we write business logic, you need to understand how routing works in Next.js 14. The App Router uses a file-system-based routing convention inside the app directory.
Every folder inside app becomes a route segment. A page.tsx file inside that folder makes the route publicly accessible. A layout.tsx file wraps the page and persists across navigations within that segment.
Here is a quick mental model:
| File Path | URL Route | Purpose |
|---|---|---|
app/page.tsx | / | Home page |
app/dashboard/page.tsx | /dashboard | Dashboard page |
app/dashboard/layout.tsx | /dashboard/* | Shared layout for dashboard |
app/api/tasks/route.ts | /api/tasks | API endpoint |
app/tasks/[id]/page.tsx | /tasks/123 | Dynamic task page |
The key difference from the old Pages Router: layouts are preserved across navigations. If your dashboard has a sidebar defined in layout.tsx, that sidebar does not re-render when you navigate between dashboard sub-pages. This is a massive performance win.
Server Components vs Client Components
This is the concept that trips up most developers coming from traditional React. In Next.js 14, every component is a Server Component by default. Server Components render on the server and send HTML to the client. They never ship JavaScript to the browser.
Why does this matter? Because Server Components can:
- Directly query your database without an API layer
- Access server-side environment variables securely
- Reduce bundle size since their code never reaches the client
- Fetch data without
useEffectoruseState
Client Components are opted into with the "use client" directive at the top of the file. You need Client Components when you use:
useState,useEffect, or other React hooks- Browser APIs like
windoworlocalStorage - Event handlers like
onClickoronChange - Third-party libraries that use browser features
The golden rule: keep components on the server unless they absolutely need interactivity. Your data-fetching, layout, and display components should be Server Components. Only interactive pieces — forms, modals, dropdowns — need to be Client Components.
Setting Up Prisma and the Database
Initialize Prisma with SQLite:
npx prisma init --datasource-provider sqlite
This creates a prisma/schema.prisma file and a .env file. Open the schema and define our models:
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
password String
name String
tasks Task[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Task {
id String @id @default(cuid())
title String
description String?
completed Boolean @default(false)
priority String @default("medium")
dueDate DateTime?
userId String
user User @relation(fields: [userId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
The .env file should already have:
DATABASE_URL="file:./dev.db"
Run the migration:
npx prisma migrate dev --name init
Now create a Prisma client singleton. This is important — without it, Next.js hot reloading creates multiple database connections during development:
// 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 the Authentication System
We need user registration and login. Create a simple JWT-based auth system. First, add a secret to your .env:
JWT_SECRET="your-super-secret-key-change-this"
Create the auth utility:
// src/lib/auth.ts
import jwt from 'jsonwebtoken'
import bcrypt from 'bcryptjs'
import { cookies } from 'next/headers'
const JWT_SECRET = process.env.JWT_SECRET!
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, 12)
}
export async function verifyPassword(
password: string,
hashedPassword: string
): Promise<boolean> {
return bcrypt.compare(password, hashedPassword)
}
export function generateToken(userId: string): string {
return jwt.sign({ userId }, JWT_SECRET, { expiresIn: '7d' })
}
export function verifyToken(token: string): { userId: string } | null {
try {
return jwt.verify(token, JWT_SECRET) as { userId: string }
} catch {
return null
}
}
export async function getCurrentUser() {
const cookieStore = await cookies()
const token = cookieStore.get('auth-token')?.value
if (!token) return null
const payload = verifyToken(token)
if (!payload) return null
return payload.userId
}
Registration Server Action
Server Actions are async functions that run on the server. They can be called directly from Client Components — no API routes needed. Think of them as RPC calls that Next.js handles automatically.
// src/app/actions/auth.ts
'use server'
import { prisma } from '@/lib/prisma'
import { hashPassword, verifyPassword, generateToken } from '@/lib/auth'
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
export async function registerUser(formData: FormData) {
const name = formData.get('name') as string
const email = formData.get('email') as string
const password = formData.get('password') as string
if (!name || !email || !password) {
return { error: 'All fields are required' }
}
const existingUser = await prisma.user.findUnique({
where: { email }
})
if (existingUser) {
return { error: 'Email already registered' }
}
const hashedPassword = await hashPassword(password)
const user = await prisma.user.create({
data: { name, email, password: hashedPassword }
})
const token = generateToken(user.id)
const cookieStore = await cookies()
cookieStore.set('auth-token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7 // 7 days
})
redirect('/dashboard')
}
export async function loginUser(formData: FormData) {
const email = formData.get('email') as string
const password = formData.get('password') as string
if (!email || !password) {
return { error: 'All fields are required' }
}
const user = await prisma.user.findUnique({
where: { email }
})
if (!user) {
return { error: 'Invalid credentials' }
}
const isValid = await verifyPassword(password, user.password)
if (!isValid) {
return { error: 'Invalid credentials' }
}
const token = generateToken(user.id)
const cookieStore = await cookies()
cookieStore.set('auth-token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7
})
redirect('/dashboard')
}
export async function logoutUser() {
const cookieStore = await cookies()
cookieStore.delete('auth-token')
redirect('/login')
}
The Registration Form (Client Component)
Since this form needs interactivity — state management, form submission handling, error display — it must be a Client Component:
// src/app/register/RegisterForm.tsx
'use client'
import { useFormState, useFormStatus } from 'react-dom'
import { registerUser } from '@/app/actions/auth'
import Link from 'next/link'
function SubmitButton() {
const { pending } = useFormStatus()
return (
<button
type="submit"
disabled={pending}
className="w-full bg-blue-600 text-white py-2 px-4 rounded-lg
hover:bg-blue-700 disabled:opacity-50"
>
{pending ? 'Creating account...' : 'Create Account'}
</button>
)
}
export default function RegisterForm() {
const [state, action] = useFormState(registerUser, null)
return (
<form action={action} className="space-y-4 max-w-md mx-auto mt-20">
<h1 className="text-2xl font-bold">Create Account</h1>
{state?.error && (
<p className="text-red-500 text-sm">{state.error}</p>
)}
<input name="name" type="text" placeholder="Full Name"
className="w-full p-2 border rounded-lg" required />
<input name="email" type="email" placeholder="Email"
className="w-full p-2 border rounded-lg" required />
<input name="password" type="password" placeholder="Password"
className="w-full p-2 border rounded-lg" required minLength={6} />
<SubmitButton />
<p className="text-sm text-gray-600">
Already have an account?{' '}
<Link href="/login" className="text-blue-600 hover:underline">
Log in
</Link>
</p>
</form>
)
}
The page file is a Server Component that renders the form:
// src/app/register/page.tsx
import RegisterForm from './RegisterForm'
export default function RegisterPage() {
return <RegisterForm />
}
Build a similar LoginForm component at src/app/login/LoginForm.tsx using the loginUser action.
Middleware for Route Protection
Next.js middleware runs before a request is completed. We use it to protect routes that require authentication:
// src/middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
const token = request.cookies.get('auth-token')?.value
const { pathname } = request.nextUrl
// Protected routes
if (pathname.startsWith('/dashboard')) {
if (!token) {
return NextResponse.redirect(new URL('/login', request.url))
}
}
// Redirect logged-in users away from auth pages
if (pathname === '/login' || pathname === '/register') {
if (token) {
return NextResponse.redirect(new URL('/dashboard', request.url))
}
}
return NextResponse.next()
}
export const config = {
matcher: ['/dashboard/:path*', '/login', '/register']
}
Notice that middleware cannot verify the JWT directly because it runs on the Edge runtime, which has limited API support. For a production app, you would use a lightweight JWT library compatible with the Edge runtime, or use a service like NextAuth.js. For our tutorial, checking for the cookie's existence is sufficient — the Server Actions verify the token properly.
Building the Task Dashboard
This is where Server Components shine. The dashboard page fetches tasks directly from the database — no API call, no loading state, no useEffect:
// src/app/dashboard/page.tsx
import { prisma } from '@/lib/prisma'
import { getCurrentUser } from '@/lib/auth'
import { redirect } from 'next/navigation'
import TaskList from './TaskList'
import AddTaskForm from './AddTaskForm'
export default async function DashboardPage() {
const userId = await getCurrentUser()
if (!userId) redirect('/login')
const tasks = await prisma.task.findMany({
where: { userId },
orderBy: { createdAt: 'desc' }
})
const user = await prisma.user.findUnique({
where: { id: userId },
select: { name: true }
})
return (
<div className="max-w-4xl mx-auto p-6">
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold">
Welcome, {user?.name}
</h1>
<form action={logoutUser}>
<button className="text-red-500 hover:underline">
Logout
</button>
</form>
</div>
<AddTaskForm />
<div className="mt-8">
<h2 className="text-xl font-semibold mb-4">
Your Tasks ({tasks.length})
</h2>
<TaskList tasks={tasks} />
</div>
</div>
)
}
See what happened there? We called prisma.task.findMany() directly in the component. No API route. No fetch call. The database query runs on the server, the component renders to HTML, and the client receives the finished page. This is the power of Server Components.
Server Actions for Task CRUD
Create server actions for managing tasks:
// src/app/actions/tasks.ts
'use server'
import { prisma } from '@/lib/prisma'
import { getCurrentUser } from '@/lib/auth'
import { revalidatePath } from 'next/cache'
export async function createTask(formData: FormData) {
const userId = await getCurrentUser()
if (!userId) return { error: 'Unauthorized' }
const title = formData.get('title') as string
const description = formData.get('description') as string
const priority = formData.get('priority') as string
const dueDate = formData.get('dueDate') as string
if (!title) return { error: 'Title is required' }
await prisma.task.create({
data: {
title,
description: description || null,
priority: priority || 'medium',
dueDate: dueDate ? new Date(dueDate) : null,
userId
}
})
revalidatePath('/dashboard')
}
export async function toggleTask(taskId: string) {
const userId = await getCurrentUser()
if (!userId) return { error: 'Unauthorized' }
const task = await prisma.task.findFirst({
where: { id: taskId, userId }
})
if (!task) return { error: 'Task not found' }
await prisma.task.update({
where: { id: taskId },
data: { completed: !task.completed }
})
revalidatePath('/dashboard')
}
export async function deleteTask(taskId: string) {
const userId = await getCurrentUser()
if (!userId) return { error: 'Unauthorized' }
await prisma.task.deleteMany({
where: { id: taskId, userId }
})
revalidatePath('/dashboard')
}
export async function updateTask(formData: FormData) {
const userId = await getCurrentUser()
if (!userId) return { error: 'Unauthorized' }
const taskId = formData.get('taskId') as string
const title = formData.get('title') as string
const description = formData.get('description') as string
const priority = formData.get('priority') as string
await prisma.task.update({
where: { id: taskId },
data: { title, description, priority }
})
revalidatePath('/dashboard')
}
The revalidatePath('/dashboard') call is crucial. It tells Next.js to re-render the dashboard page with fresh data after a mutation. Without it, the page would show stale data until the user manually refreshed.
The Add Task Form
// src/app/dashboard/AddTaskForm.tsx
'use client'
import { useFormStatus } from 'react-dom'
import { createTask } from '@/app/actions/tasks'
import { useRef } from 'react'
function SubmitButton() {
const { pending } = useFormStatus()
return (
<button type="submit" disabled={pending}
className="bg-blue-600 text-white px-6 py-2 rounded-lg
hover:bg-blue-700 disabled:opacity-50">
{pending ? 'Adding...' : 'Add Task'}
</button>
)
}
export default function AddTaskForm() {
const formRef = useRef<HTMLFormElement>(null)
async function handleSubmit(formData: FormData) {
await createTask(formData)
formRef.current?.reset()
}
return (
<form ref={formRef} action={handleSubmit}
className="bg-gray-50 p-4 rounded-lg space-y-3">
<div className="flex gap-3">
<input name="title" type="text" placeholder="Task title"
className="flex-1 p-2 border rounded-lg" required />
<select name="priority" className="p-2 border rounded-lg">
<option value="low">Low</option>
<option value="medium" selected>Medium</option>
<option value="high">High</option>
</select>
</div>
<input name="description" type="text"
placeholder="Description (optional)"
className="w-full p-2 border rounded-lg" />
<div className="flex justify-between items-center">
<input name="dueDate" type="date"
className="p-2 border rounded-lg" />
<SubmitButton />
</div>
</form>
)
}
The Task List Component
// src/app/dashboard/TaskList.tsx
'use client'
import { toggleTask, deleteTask } from '@/app/actions/tasks'
type Task = {
id: string
title: string
description: string | null
completed: boolean
priority: string
dueDate: Date | null
}
function PriorityBadge({ priority }: { priority: string }) {
const colors: Record<string, string> = {
high: 'bg-red-100 text-red-700',
medium: 'bg-yellow-100 text-yellow-700',
low: 'bg-green-100 text-green-700'
}
return (
<span className={`px-2 py-1 rounded text-xs font-medium ${colors[priority]}`}>
{priority}
</span>
)
}
export default function TaskList({ tasks }: { tasks: Task[] }) {
if (tasks.length === 0) {
return (
<p className="text-gray-500 text-center py-8">
No tasks yet. Add one above to get started.
</p>
)
}
return (
<div className="space-y-2">
{tasks.map((task) => (
<div key={task.id}
className={`flex items-center gap-3 p-4 border rounded-lg
${task.completed ? 'bg-gray-50 opacity-60' : 'bg-white'}`}>
<button onClick={() => toggleTask(task.id)}
className={`w-5 h-5 rounded border-2 flex-shrink-0
${task.completed
? 'bg-blue-600 border-blue-600'
: 'border-gray-300'}`}
/>
<div className="flex-1">
<p className={`font-medium ${
task.completed ? 'line-through text-gray-400' : ''
}`}>
{task.title}
</p>
{task.description && (
<p className="text-sm text-gray-500">{task.description}</p>
)}
</div>
<PriorityBadge priority={task.priority} />
<button onClick={() => deleteTask(task.id)}
className="text-red-400 hover:text-red-600 text-sm">
Delete
</button>
</div>
))}
</div>
)
}
Adding API Routes
Sometimes you need traditional API endpoints — for webhooks, third-party integrations, or mobile apps. Next.js 14 uses Route Handlers:
// src/app/api/tasks/route.ts
import { prisma } from '@/lib/prisma'
import { getCurrentUser } from '@/lib/auth'
import { NextResponse } from 'next/server'
export async function GET() {
const userId = await getCurrentUser()
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const tasks = await prisma.task.findMany({
where: { userId },
orderBy: { createdAt: 'desc' }
})
return NextResponse.json(tasks)
}
export async function POST(request: Request) {
const userId = await getCurrentUser()
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const task = await prisma.task.create({
data: {
title: body.title,
description: body.description || null,
priority: body.priority || 'medium',
dueDate: body.dueDate ? new Date(body.dueDate) : null,
userId
}
})
return NextResponse.json(task, { status: 201 })
}
Each exported function name corresponds to an HTTP method. GET, POST, PUT, DELETE, PATCH — they all work. The request object is the standard Web API Request, and you return a NextResponse.
The Dashboard Layout
Create a layout that wraps all dashboard pages with a consistent sidebar or navigation:
// src/app/dashboard/layout.tsx
import { logoutUser } from '@/app/actions/auth'
export default function DashboardLayout({
children
}: {
children: React.ReactNode
}) {
return (
<div className="min-h-screen bg-gray-100">
<nav className="bg-white shadow-sm border-b">
<div className="max-w-4xl mx-auto px-6 py-3 flex justify-between">
<h1 className="font-bold text-lg">Task Manager</h1>
<form action={logoutUser}>
<button className="text-sm text-gray-500 hover:text-red-500">
Sign Out
</button>
</form>
</div>
</nav>
<main className="py-6">{children}</main>
</div>
)
}
This layout persists when navigating between dashboard sub-pages. The navbar does not re-render, only the {children} content swaps.
Data Fetching Patterns
Next.js 14 gives you multiple ways to fetch data. Here is when to use each:
Server Component Direct Fetch
Use this for data that is needed on initial page load. The data is fetched on the server and rendered into HTML:
// This is a Server Component (default)
async function TaskStats() {
const stats = await prisma.task.groupBy({
by: ['priority'],
_count: { id: true }
})
return <StatsChart data={stats} />
}
Server Actions
Use these for mutations (creating, updating, deleting data). They are called from forms or Client Components:
'use server'
export async function markAllComplete() {
await prisma.task.updateMany({
where: { userId: currentUserId, completed: false },
data: { completed: true }
})
revalidatePath('/dashboard')
}
Route Handlers
Use these when you need a traditional REST API — for external consumers, webhooks, or when Client Components need to fetch data dynamically.
Client-Side Fetching
Use fetch inside a useEffect when you need real-time updates or data that changes frequently without page reloads. This is the old pattern — avoid it unless genuinely necessary.
Deploying to Vercel
Deploying a Next.js app to Vercel is straightforward. First, push your code to a GitHub repository:
git init
git add .
git commit -m "Initial commit"
git remote add origin https://github.com/yourusername/task-manager.git
git push -u origin main
Then go to vercel.com, import the repository, and deploy. Vercel auto-detects Next.js and configures everything.
For the database, SQLite will not work on Vercel because the filesystem is ephemeral. You need to switch to a hosted database. The easiest options:
- Vercel Postgres — native integration, generous free tier
- PlanetScale — MySQL-compatible, great developer experience
- Supabase — PostgreSQL with a nice dashboard
Update your Prisma schema provider and DATABASE_URL accordingly. For Vercel Postgres:
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
directUrl = env("DIRECT_URL")
}
Set your environment variables in Vercel's dashboard, run npx prisma migrate deploy, and you are live.
Performance Tips
A few things I have learned from running Next.js 14 apps in production:
-
Use
loading.tsxfiles for instant loading states. Place aloading.tsxalongside yourpage.tsx, and Next.js will show it immediately while the page data loads. -
Leverage parallel routes for dashboards with multiple data-heavy sections. Each section can load independently.
-
Use
generateStaticParamsfor pages with dynamic routes that can be pre-rendered at build time. -
Keep Client Components small. Every Client Component boundary ships JavaScript. Push interactivity to the leaf nodes of your component tree.
-
Use
unstable_cachefor expensive database queries that do not need real-time data.
What We Covered
This tutorial walked through building a complete full-stack application with Next.js 14. We covered the App Router's file-based routing, the difference between Server Components and Client Components, setting up Prisma for database operations, writing Server Actions for mutations, protecting routes with middleware, building API routes, and deploying to production.
The task manager app is fully functional, but there is plenty of room to extend it. You could add task categories, drag-and-drop reordering with a Client Component library, email notifications with a cron job, or a shared workspace feature.
The patterns demonstrated here — Server Components for data display, Server Actions for mutations, middleware for auth, Prisma for database access — are the same patterns used in production Next.js applications at companies like Vercel, Notion, and HashiCorp. Master these, and you can build essentially any web application.
Advertisement
Advertisement
Ad Space
Anurag Sharma
Founder & Editor
Tech enthusiast and founder of Tech Tips India. Passionate about making technology accessible to everyone across India.
Comments (0)
Leave a Comment
Related Articles
API Design Best Practices: REST, GraphQL, and the Patterns That Scale
A practical guide to designing APIs that last, covering REST conventions, GraphQL fundamentals, tRPC, authentication patterns, rate limiting, error handling, and testing tools with Node.js examples.
DSA Roadmap for Campus Placements: What Actually Matters in 2026
A realistic DSA preparation roadmap for campus placements in India, covering topic priorities, platform choices, language selection, company-wise patterns, and time management strategies.
Getting Started with AI in 2026: A Beginner's Complete Guide
Artificial Intelligence is transforming every industry. Learn the fundamentals of AI, popular tools, and how to begin your AI journey in 2026.