How to Build a Fullstack App with Next.js 15: Complete Guide
A hands-on guide to building a fullstack application with Next.js 15 — covering App Router, Server Actions, database integration, authentication, and deployment.
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.
Project Setup
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
Database Schema with 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 initDatabase Client Setup
// 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.
Server Components: Fetching Data Without API Routes
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: Mutations Without API Routes
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')
}Client Component with Server Actions
// 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.
Authentication with NextAuth.js v5
// 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 } = handlersMiddleware for Route Protection
// 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'],
}Common Mistakes in Next.js 15
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.
Key Takeaways
- Next.js 15 lets you build fullstack apps without a separate backend
- Server Components handle data fetching — no useEffect, no loading state, faster
- Server Actions handle mutations — validated, authenticated, no REST endpoint needed
- Always validate inputs in Server Actions — they're just HTTP POST requests at the network level
- Use
revalidatePathafter every mutation to keep the cache fresh - Prisma with the singleton pattern is the most ergonomic database setup for Next.js

Written by
Sabir KhaloufiFull-stack developer and tech blogger sharing in-depth tutorials on React, Next.js, AI, and modern web development.