Next.js has evolved from a React framework into a complete fullstack solution. With version 15, you can build a production-ready app — frontend, backend, database access, auth — all in one project, all in TypeScript. No separate Express server. No complex deployment setup.
This guide builds a real task management app from scratch. Along the way, you'll learn the patterns that matter for production: Server Components, Server Actions, database integration with Prisma, and authentication.
npx create-next-app@latest taskapp --typescript --tailwind --eslint --app
cd taskapp
npm install prisma @prisma/client
npm install next-auth@beta
npx prisma initYour project structure will look like this:
taskapp/
├── app/
│ ├── layout.tsx
│ ├── page.tsx
│ ├── (auth)/
│ │ ├── login/page.tsx
│ │ └── register/page.tsx
│ └── tasks/
│ ├── page.tsx
│ └── [id]/page.tsx
├── lib/
│ ├── db.ts
│ └── auth.ts
├── components/
└── prisma/
└── schema.prisma
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
name String?
password String
tasks Task[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Task {
id String @id @default(cuid())
title String
description String?
status TaskStatus @default(TODO)
priority Priority @default(MEDIUM)
dueDate DateTime?
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
enum TaskStatus {
TODO
IN_PROGRESS
DONE
}
enum Priority {
LOW
MEDIUM
HIGH
}npx prisma migrate dev --name init// lib/db.ts
import { PrismaClient } from '@prisma/client'
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
export const db =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
})
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = dbThe globalThis pattern prevents creating multiple Prisma instances during hot reload in development — a common mistake that causes connection pool exhaustion.
This is the key shift in Next.js 13+. Server Components run only on the server. You can call your database directly, no API needed:
// app/tasks/page.tsx
import { db } from '@/lib/db'
import { auth } from '@/lib/auth'
import { redirect } from 'next/navigation'
import TaskList from '@/components/TaskList'
export default async function TasksPage() {
const session = await auth()
if (!session?.user?.id) {
redirect('/login')
}
const tasks = await db.task.findMany({
where: { userId: session.user.id },
orderBy: [
{ status: 'asc' },
{ createdAt: 'desc' },
],
})
return (
<div className="max-w-4xl mx-auto p-6">
<h1 className="text-2xl font-bold mb-6">My Tasks</h1>
<TaskList initialTasks={tasks} />
</div>
)
}No useEffect. No useState for loading. No API call. The data is fetched during server rendering and passed directly to the component. This is faster, more secure, and simpler.
Server Actions are the other half of the equation. They're async functions that run on the server, triggered from client components:
// app/tasks/actions.ts
'use server'
import { db } from '@/lib/db'
import { auth } from '@/lib/auth'
import { revalidatePath } from 'next/cache'
import { z } from 'zod'
const createTaskSchema = z.object({
title: z.string().min(1, 'Title is required').max(100),
description: z.string().optional(),
priority: z.enum(['LOW', 'MEDIUM', 'HIGH']).default('MEDIUM'),
dueDate: z.string().optional(),
})
export async function createTask(formData: FormData) {
const session = await auth()
if (!session?.user?.id) {
throw new Error('Unauthorized')
}
const parsed = createTaskSchema.safeParse({
title: formData.get('title'),
description: formData.get('description'),
priority: formData.get('priority'),
dueDate: formData.get('dueDate'),
})
if (!parsed.success) {
return { error: parsed.error.flatten().fieldErrors }
}
await db.task.create({
data: {
...parsed.data,
userId: session.user.id,
dueDate: parsed.data.dueDate ? new Date(parsed.data.dueDate) : null,
},
})
revalidatePath('/tasks')
return { success: true }
}
export async function updateTaskStatus(taskId: string, status: 'TODO' | 'IN_PROGRESS' | 'DONE') {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
// Verify ownership before updating
const task = await db.task.findFirst({
where: { id: taskId, userId: session.user.id },
})
if (!task) throw new Error('Task not found')
await db.task.update({
where: { id: taskId },
data: { status },
})
revalidatePath('/tasks')
}
export async function deleteTask(taskId: string) {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
await db.task.deleteMany({
where: { id: taskId, userId: session.user.id },
})
revalidatePath('/tasks')
}// components/CreateTaskForm.tsx
'use client'
import { useActionState } from 'react'
import { createTask } from '@/app/tasks/actions'
const initialState = { error: null, success: false }
export default function CreateTaskForm() {
const [state, formAction, isPending] = useActionState(createTask, initialState)
return (
<form action={formAction} className="space-y-4">
<div>
<input
name="title"
placeholder="Task title"
required
className="w-full border rounded-lg px-3 py-2 text-sm"
/>
{state?.error?.title && (
<p className="text-red-500 text-xs mt-1">{state.error.title[0]}</p>
)}
</div>
<textarea
name="description"
placeholder="Description (optional)"
className="w-full border rounded-lg px-3 py-2 text-sm"
rows={3}
/>
<select name="priority" className="border rounded-lg px-3 py-2 text-sm">
<option value="LOW">Low</option>
<option value="MEDIUM">Medium</option>
<option value="HIGH">High</option>
</select>
<input type="date" name="dueDate" className="border rounded-lg px-3 py-2 text-sm" />
<button
type="submit"
disabled={isPending}
className="bg-blue-600 text-white px-4 py-2 rounded-lg text-sm disabled:opacity-50"
>
{isPending ? 'Creating...' : 'Create Task'}
</button>
</form>
)
}The useActionState hook (new in React 19 / Next.js 15) gives you the action state and a pending indicator without any manual state management.
// lib/auth.ts
import NextAuth from 'next-auth'
import Credentials from 'next-auth/providers/credentials'
import { db } from '@/lib/db'
import bcrypt from 'bcryptjs'
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [
Credentials({
credentials: {
email: { label: 'Email', type: 'email' },
password: { label: 'Password', type: 'password' },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) return null
const user = await db.user.findUnique({
where: { email: credentials.email as string },
})
if (!user) return null
const passwordMatch = await bcrypt.compare(
credentials.password as string,
user.password
)
if (!passwordMatch) return null
return { id: user.id, email: user.email, name: user.name }
},
}),
],
callbacks: {
jwt({ token, user }) {
if (user) token.id = user.id
return token
},
session({ session, token }) {
session.user.id = token.id as string
return session
},
},
pages: {
signIn: '/login',
},
})// app/api/auth/[...nextauth]/route.ts
import { handlers } from '@/lib/auth'
export const { GET, POST } = handlers// middleware.ts
import { auth } from '@/lib/auth'
import { NextResponse } from 'next/server'
export default auth((req) => {
const isLoggedIn = !!req.auth
const isAuthPage = req.nextUrl.pathname.startsWith('/login') ||
req.nextUrl.pathname.startsWith('/register')
if (!isLoggedIn && !isAuthPage) {
return NextResponse.redirect(new URL('/login', req.url))
}
if (isLoggedIn && isAuthPage) {
return NextResponse.redirect(new URL('/tasks', req.url))
}
})
export const config = {
matcher: ['/tasks/:path*', '/login', '/register'],
}1. Putting everything in Client Components. Move data fetching to Server Components and only use 'use client' when you need interactivity.
2. Not validating Server Action inputs. Server Actions are API endpoints — always validate with Zod or similar.
3. Forgetting revalidatePath after mutations. Without it, the page cache won't update and users will see stale data.
4. Multiple Prisma instances. Always use the singleton pattern shown above.
5. Exposing sensitive data in Server Components. A Server Component can accidentally pass sensitive data to a Client Component as props. Be deliberate about what you expose.
revalidatePath after every mutation to keep the cache fresh