Skip to main content

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.

Anurag Sharma
17 min read
Next.js 14 Complete Tutorial: Build a Full-Stack App Step by Step

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 PathURL RoutePurpose
app/page.tsx/Home page
app/dashboard/page.tsx/dashboardDashboard page
app/dashboard/layout.tsx/dashboard/*Shared layout for dashboard
app/api/tasks/route.ts/api/tasksAPI endpoint
app/tasks/[id]/page.tsx/tasks/123Dynamic 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 useEffect or useState

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 window or localStorage
  • Event handlers like onClick or onChange
  • 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:

  1. Use loading.tsx files for instant loading states. Place a loading.tsx alongside your page.tsx, and Next.js will show it immediately while the page data loads.

  2. Leverage parallel routes for dashboards with multiple data-heavy sections. Each section can load independently.

  3. Use generateStaticParams for pages with dynamic routes that can be pre-rendered at build time.

  4. Keep Client Components small. Every Client Component boundary ships JavaScript. Push interactivity to the leaf nodes of your component tree.

  5. Use unstable_cache for 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

Share

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