CodeWithSabir
HomeAIDevOpsNext.jsMobile DevelopmentWeb Development
CodeWithSabir
  • Home
  • AI
  • DevOps
  • Next.js
  • Mobile Development
  • Web Development
  • About
  • Contact
CodeWithSabir

In-depth articles, tutorials, and guides on web development, React, Next.js, AI, and modern programming practices.

Topics

  • AI
  • DevOps
  • Next.js
  • Mobile Development
  • Web Development

Company

  • About
  • Contact
  • Privacy Policy
  • Terms

© 2026 CodeWithSabir. All rights reserved.

Built with SabirSoft.com

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

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.

Sabir KhaloufiSabir KhaloufiFebruary 25, 20265 min read

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

bash
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:

code
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
// 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
}
bash
npx prisma migrate dev --name init

Database Client Setup

typescript
// 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:

typescript
// 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:

typescript
// 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

tsx
// 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

typescript
// 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',
  },
})
typescript
// app/api/auth/[...nextauth]/route.ts
import { handlers } from '@/lib/auth'
export const { GET, POST } = handlers

Middleware for Route Protection

typescript
// 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
#next.js 15#fullstack#app router#server actions#react
Share:
Sabir Khaloufi — author photo

Written by

Sabir Khaloufi

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

On this page

  • Project Setup
  • Database Schema with Prisma
  • Database Client Setup
  • Server Components: Fetching Data Without API Routes
  • Server Actions: Mutations Without API Routes
  • Client Component with Server Actions
  • Authentication with NextAuth.js v5
  • Middleware for Route Protection
  • Common Mistakes in Next.js 15
  • Key Takeaways

Related Articles

Next.js

Next.js Server Components Explained: What They Are and Why They Matter

A clear explanation of React Server Components in Next.js — what problem they solve, how they differ from Client Components, and the patterns that make them powerful.

Sabir KhaloufiFebruary 20, 20264 min read
Next.js

Authentication in Next.js with NextAuth.js v5: The Complete Setup

Learn how to implement authentication in Next.js using NextAuth.js v5 — covering credentials, OAuth providers, JWT sessions, protected routes, and role-based access control.

Sabir KhaloufiFebruary 15, 20264 min read
Next.js

Next.js Performance Optimization: 10 Techniques That Make a Real Difference

Practical Next.js performance optimizations covering image optimization, caching strategies, bundle analysis, lazy loading, and Core Web Vitals improvements that affect Google rankings.

Sabir KhaloufiFebruary 10, 20264 min read