Skip to main content

Next.js 14 Tutorial: Build a Full-Stack App

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
20 min read
Next.js 14 Tutorial: Build a Full-Stack App

The Problem Next.js Solves

Before Next.js, building a React app with server-side rendering was painful. Really painful. You'd wire up Express, configure webpack manually, figure out code splitting by hand, wrestle with hydration mismatches, and spend days on plumbing before writing a single line of business logic. I've done it. Twice. Wouldn't recommend it.

Next.js took all that pain away. But version 14 in particular changed things in a way that matters. Server Components and Server Actions aren't experimental toys anymore — they're the default. You can query your database directly inside a component. You can handle form submissions without writing API routes. And the performance you get out of the box? It's just better than what most of us were hand-rolling before.

So here's what we're going to do. We'll build a full-stack task manager application from scratch. Not a toy demo — a real, working app with authentication, a database, server-side rendering, and deployment. By the end, you'll have something running on Vercel that you can actually use every day. I think that's the best way to learn any framework: build something you'd actually open on a Monday morning.

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 (if you want to level up your utility-class skills, check out our Tailwind CSS tips and tricks guide)

I chose this stack because it's how modern full-stack React apps get built in production today. Server Components and Server Actions aren't edge cases anymore. Understanding them is probably the most important thing for any React developer moving forward. If you're weighing React against other modern frameworks, our comparison of React vs Vue vs Svelte covers the tradeoffs in detail.


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. That gives us a project with TypeScript, Tailwind CSS, ESLint, the App Router, and a src directory structure. Quick and clean.

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

Nothing surprising so far. Let's get into the interesting parts.


Understanding the App Router

Before we write business logic, you need to understand how routing works in Next.js 14. The App Router uses file-system-based routing inside the app directory. Every folder 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's 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/123Variable-path task page

Here's what's different from the old Pages Router: layouts are preserved across navigations. If your dashboard has a sidebar defined in layout.tsx, that sidebar won't re-render when you move between dashboard sub-pages. Massive performance win. Once you see it in action, you'll wonder why it wasn't always done this way.


Server Components vs Client Components

Right. Here's the concept that trips up most developers coming from traditional React. In Next.js 14, every component is a Server Component by default. They render on the server, send HTML to the client, and never ship JavaScript to the browser.

Why should you care? 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 what you opt into with the "use client" directive at the top of the file. You need them when you're using:

  • useState, useEffect, or other React hooks
  • Browser APIs like window or localStorage
  • Event handlers like onClick or onChange
  • Third-party libraries that depend on browser features

Here's the golden rule I keep coming back to: keep components on the server unless they absolutely need interactivity. Data-fetching components, layouts, display-only components — those should all be Server Components. Only forms, modals, dropdowns, and other interactive pieces need Client Components. Follow that rule and you'll probably end up with a faster app than you expected.


Setting Up Prisma and the Database

Initialize Prisma with SQLite:

npx prisma init --datasource-provider sqlite

That 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
}

Your .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. Without it, Next.js hot reloading spawns multiple database connections during development, and things get messy fast:

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

That's our database ready. On to the fun stuff.


Building the Authentication System

We need user registration and login. Let's build 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
}

Nothing fancy. Hash passwords with bcrypt, sign tokens with JWT, read the current user from cookies. It's straightforward, and that's the point — you don't want auth code to be clever.

Registration Server Action

Server Actions are async functions that run on the server. You can call them directly from Client Components — no API routes needed. Think of them as RPC calls that Next.js handles for you automatically. Seems like magic the first time you see it work.

// 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')
}

Look at that. Database query, password hashing, cookie setting, redirect — all in one function that a form can call directly. No fetch. No API route. No loading state management. Server Actions collapse a lot of boilerplate.

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>
  )
}

Notice the useFormStatus hook in the SubmitButton component. While the form's submitting, the button shows "Creating account..." and disables itself. No manual loading state needed. Clean.

And the page file is just 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. Same pattern, fewer fields.


Middleware for Route Protection

Next.js middleware runs before a request completes. We'll 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']
}

Worth noting: middleware can't verify the JWT directly because it runs on the Edge runtime, which has limited API support. For a production app, you'd use a lightweight JWT library compatible with the Edge runtime, or reach for something like NextAuth.js. For our tutorial, checking for the cookie's existence is enough — Server Actions verify the token properly when they run. Not perfect, but it works fine for learning.


Building the Task Dashboard

Here's where Server Components really shine. Watch what happens. 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 just happened? We called prisma.task.findMany() directly in the component. No API route. No fetch call. No useEffect. The database query runs on the server, the component renders to HTML, and the client receives the finished page. If you're coming from traditional React where everything needed a REST endpoint and a loading spinner, this probably feels wrong. It isn't. It's just different, and once you get used to it, going back feels tedious.


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')
}

Pay attention to the revalidatePath('/dashboard') call. That tells Next.js to re-render the dashboard page with fresh data after a mutation. Without it, the page would show stale data until someone manually refreshed. It's a small line that does a lot of heavy lifting.


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>
  )
}

Couple things worth noting. Using useRef to grab the form element lets us reset all fields after successful submission. And the handleSubmit wrapper around createTask is where that reset happens. Simple pattern, works every time.


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>
  )
}

Each task gets a toggle button and a delete button. Both call Server Actions directly. When a task gets toggled or deleted, revalidatePath fires, the dashboard re-renders with fresh data, and the UI updates. No optimistic updates here — for a tutorial, the slight delay is fine. In production, you'd probably add useOptimistic for snappier feedback.


Adding API Routes

Sometimes you need traditional API endpoints. Webhooks, third-party integrations, mobile apps — there are legitimate reasons. Next.js 14 handles these with 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 maps 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. Straightforward if you've worked with Express or any other server framework. For a broader look at how to design clean, scalable API contracts, our guide on REST and GraphQL API design best practices is a useful companion read.


The Dashboard Layout

Create a layout that wraps all dashboard pages with consistent 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>
  )
}

Remember what we discussed about layouts? When you move between dashboard sub-pages, the navbar doesn't re-render. Only the {children} content swaps out. Users see instant transitions because the layout shell stays put.


Data Fetching Patterns

Next.js 14 gives you multiple ways to fetch data. Knowing when to use each one matters. Let me walk through them.

Server Component Direct Fetch

Reach for this when data's needed on initial page load. Runs on the server, renders into HTML, ships to the client:

// This is a Server Component (default)
async function TaskStats() {
  const stats = await prisma.task.groupBy({
    by: ['priority'],
    _count: { id: true }
  })
  return <StatsChart data={stats} />
}

No loading spinner. No useEffect. No race conditions. Just data, rendered.

Server Actions

Reach for these when you're mutating data — creating, updating, deleting. They're 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

Reach for these when you need a traditional REST API — for external consumers, webhooks, or situations where Client Components need to fetch data on the fly.

Client-Side Fetching

Using fetch inside a useEffect still works for real-time updates or data that changes frequently without page reloads. But it's the old pattern. Avoid it unless you genuinely need it. Most of the time, Server Components and Server Actions cover your needs better.


Deploying to Vercel

Deploying a Next.js app to Vercel is about as simple as deployment gets. 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 head to vercel.com, import the repository, and deploy. Vercel auto-detects Next.js and configures everything. I think the whole process takes under two minutes if your repo's clean.

One catch: SQLite won't work on Vercel because the filesystem is ephemeral. You need a hosted database. 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're live. Actually live. On the internet. Probably took less time than configuring a traditional server would've.


Performance Tips

A few things I've picked up from running Next.js 14 apps in production. Some of these might seem obvious, but I've seen people miss them:

  1. Use loading.tsx files for instant loading states. Place a loading.tsx alongside your page.tsx, and Next.js shows it immediately while the page data loads. Users see something right away instead of staring at a blank screen. Small thing, big difference.

  2. Use parallel routes for dashboards with multiple data-heavy sections. Each section loads independently, so a slow database query in one panel doesn't block the others.

  3. Use generateStaticParams for pages with parameterized routes that can be pre-rendered at build time. Product pages, blog posts, documentation — anything that doesn't change on every request.

  4. Keep Client Components small. Every Client Component boundary ships JavaScript to the browser. Push interactivity to the leaf nodes of your component tree, not the trunk. I suspect most bundle size problems come from making entire page sections into Client Components when only a button inside them needed interactivity.

  5. Use unstable_cache for expensive database queries that don't need real-time data. The name says "unstable" but it works fine in practice. Arguably it should've been renamed by now.


Now Go Build Something Real

Here's what we covered: the App Router's file-based routing, Server Components versus Client Components, Prisma for database operations, Server Actions for mutations, middleware for route protection, API routes for external consumers, and deploying to production. That's a full-stack application, soup to nuts, using patterns that real companies ship with.

But a tutorial only gets you so far. Reading about Server Components and actually wrestling with them in your own project are different experiences entirely. So here's my suggestion: don't just close this tab and move on. Take this task manager and change it. Add task categories. Build a drag-and-drop board with a Client Component library. Wire up email notifications. Break something and fix it.

The patterns we built here — Server Components for data display, Server Actions for mutations, middleware for auth, Prisma for database access — they're the same patterns running in production at companies like Vercel, Notion, and HashiCorp. Master them by building, not by reading. You've got the foundation now. What you build on top of it is up to you.

Share

Anurag Sharma

Founder & Editor

Software engineer with 8+ years of experience in full-stack development and cloud architecture. Founder of Tech Tips India, where he breaks down complex tech concepts into practical, actionable guides for Indian developers and enthusiasts.

Stay Ahead in Tech

Get the latest tech news, tutorials, and reviews delivered straight to your inbox every week.

No spam ever. Unsubscribe anytime.

Comments (0)

Leave a Comment

All comments are moderated before appearing. Please be respectful and follow our community guidelines.

Related Articles