Follow Us

CodeWithSabir

  • Contact Us
  • Privacy Policy
  • About
  • Terms & Conditions

All Rights Reserved © 2026

  • Light
  • Dark
Next.js

How to Build a Fullstack App with Next.js 15: Complete Guide

Sabir Soft
Sabir Lkhaloufi
  • February 25, 2026
  • 5 min read

How to Build a Fullstack App with Next.js 15: Complete Guide

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 init

Your 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 init

Database 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 = db

The 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 } = handlers

Middleware 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 revalidatePath after every mutation to keep the cache fresh
  • Prisma with the singleton pattern is the most ergonomic database setup for Next.js
Popular Blogs
Claude AI vs ChatGPT: An Honest Comparison for Developers
  • April 28, 2026
AI Tools Every Developer Should Be Using in 2026
  • April 20, 2026
Using the Claude API in Real Projects: A Practical Developer Guide
  • April 15, 2026
Prompt Engineering for Developers: Write Prompts That Actually Work
  • April 10, 2026
Categories
AIDevOpsNext.jsMobile DevelopmentWeb Development

Related Posts

Next.js
Next.js Server Components Explained: What They Are and Why They Matter
Sabir Khaloufi·Feb 20, 2026
Next.js
Authentication in Next.js with NextAuth.js v5: The Complete Setup
Sabir Khaloufi·Feb 15, 2026
Next.js
Next.js Performance Optimization: 10 Techniques That Make a Real Difference
Sabir Khaloufi·Feb 10, 2026