Back to blog

Phase 3: Next.js — From React to Full-Stack

nextjsreactapp-routerserver-componentsfrontend
Phase 3: Next.js — From React to Full-Stack

Welcome to Phase 3! In Phase 1 you learned React's component model, and in Phase 2 you added routing, state management, forms, and data fetching. Now it's time to level up: Next.js takes everything you know about React and adds a full-stack superpowers layer.

Time commitment: 6–8 days, 1–2 hours daily
Prerequisites: Phase 2: React Ecosystem

What You'll Learn

By the end of Phase 3, you'll be able to:

✅ Set up a Next.js 15 project with the App Router
✅ Use file-based routing including dynamic and nested routes
✅ Write Server Components and know when to use Client Components
✅ Choose between SSR, SSG, ISR, and CSR for each page
✅ Build API endpoints with Next.js Route Handlers
✅ Create shared layouts, loading states, and error boundaries
✅ Add SEO metadata declaratively
✅ Manage environment variables safely


Why Next.js?

React is a UI library — it handles rendering components, but it doesn't tell you how to route between pages, fetch data on the server, or build API endpoints. Next.js is a framework built on top of React that provides all of this and more.

The key things Next.js adds:

FeatureReact aloneNext.js
RoutingYou set up React RouterBuilt-in file-based routing
Data fetchinguseEffect / TanStack Query on clientServer-side fetch, cached, colocated
API endpointsNeed a separate backendRoute Handlers in the same project
SEOManual <head> managementDeclarative Metadata API
PerformanceManual code splittingAutomatic with Server Components
Image optimizationManualBuilt-in <Image> component

Project Setup

Bootstrap a new Next.js 15 project:

npx create-next-app@latest my-app

The CLI will ask:

  • TypeScript? → Yes
  • ESLint? → Yes
  • Tailwind CSS? → Yes (recommended)
  • src/ directory? → Your preference (this guide uses root app/)
  • App Router? → Yes (this is the modern approach)
  • Turbopack for next dev? → Yes (faster dev server)

After setup, the relevant structure is:

my-app/
├── app/                  # All your routes live here
│   ├── layout.tsx        # Root layout (wraps everything)
│   ├── page.tsx          # Home page (/)
│   └── globals.css
├── public/               # Static assets
├── next.config.ts        # Next.js config
└── package.json

File-Based Routing

The App Router uses the filesystem as your router. Every folder inside app/ that contains a page.tsx becomes a route.

app/
├── page.tsx              → /
├── about/
│   └── page.tsx          → /about
├── blog/
│   ├── page.tsx          → /blog
│   └── [slug]/
│       └── page.tsx      → /blog/:slug
└── dashboard/
    ├── layout.tsx        → shared layout for dashboard
    ├── page.tsx          → /dashboard
    └── settings/
        └── page.tsx      → /dashboard/settings

Dynamic Routes

Square brackets create dynamic segments:

// app/blog/[slug]/page.tsx
interface Props {
  params: Promise<{ slug: string }>
}
 
export default async function BlogPost({ params }: Props) {
  const { slug } = await params
  return <h1>Post: {slug}</h1>
}

Note: In Next.js 15, params and searchParams are Promises — you must await them.

Catch-All Routes

app/docs/[...slug]/page.tsx   → /docs/a/b/c  (params.slug = ['a','b','c'])
app/docs/[[...slug]]/page.tsx → /docs         (optional catch-all, also matches /)

Route Groups

Parentheses create groups that don't affect the URL:

app/
├── (marketing)/
│   ├── layout.tsx   ← only wraps marketing pages
│   ├── page.tsx     → /
│   └── about/
│       └── page.tsx → /about
└── (dashboard)/
    ├── layout.tsx   ← only wraps dashboard pages
    └── dashboard/
        └── page.tsx → /dashboard

This lets you apply different layouts to different sections of your site without changing the URL.


The Mental Model Shift: Server vs Client Components

This is the most important concept in modern Next.js. By default, all components in the App Router are Server Components.

Server Components

  • Run only on the server — never in the browser
  • Can be asyncawait data directly, no useEffect needed
  • Can access server-only things: databases, file system, env secrets
  • Have zero JavaScript bundle impact — their code never ships to the client
  • Cannot use browser APIs, event handlers, or React hooks
// app/blog/page.tsx — this is a Server Component by default
async function BlogPage() {
  // Fetch data directly — no useEffect, no loading state needed
  const posts = await fetch('https://api.example.com/posts').then(r => r.json())
 
  return (
    <ul>
      {posts.map((post: Post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  )
}

Client Components

Add 'use client' at the top of the file to make it a Client Component:

  • Run on the browser (and optionally pre-rendered on server)
  • Can use React hooks (useState, useEffect, useContext, etc.)
  • Can use browser APIs and event handlers
  • Their code is included in the JavaScript bundle
'use client'
 
import { useState } from 'react'
 
export function Counter() {
  const [count, setCount] = useState(0)
  return (
    <button onClick={() => setCount(count + 1)}>
      Count: {count}
    </button>
  )
}

The Composition Rule

Server Components can render Client Components, but Client Components cannot render Server Components directly:

// app/page.tsx (Server Component)
import { Counter } from '@/components/Counter'  // Client Component — fine!
 
export default function Page() {
  return (
    <div>
      <h1>Server-rendered heading</h1>
      <Counter />  {/* Client Component embedded in Server Component */}
    </div>
  )
}

Pass Server Component output as children to Client Components:

// ✅ Pattern: Server Component wraps Client Component with server-rendered children
// app/page.tsx
import { Modal } from '@/components/Modal'  // 'use client'
 
export default function Page() {
  return (
    <Modal>
      <ServerRenderedContent />  {/* Server Component as children prop */}
    </Modal>
  )
}

When to Use Each

ScenarioComponent Type
Fetch data from API or DBServer Component
Display static or server-fetched contentServer Component
Add interactivity (click, hover, etc.)Client Component
Use useState, useEffectClient Component
Access window, localStorageClient Component
Subscribe to browser eventsClient Component
Keep secrets out of the bundleServer Component

Rule of thumb: Default to Server Components. Only add 'use client' when you need interactivity or browser APIs. Push 'use client' as far down the component tree as possible — keep your leaves interactive, not your entire tree.


Rendering Strategies

Next.js supports four rendering strategies. You choose per-route based on your data needs.

Static Generation (SSG)

The default for Server Components with no dynamic data:

// This page is statically generated at build time
export default async function AboutPage() {
  const data = await fetch('https://api.example.com/about', {
    cache: 'force-cache'  // default — cache indefinitely
  }).then(r => r.json())
 
  return <div>{data.content}</div>
}

Use SSG for pages where content doesn't change often: marketing pages, documentation, blog posts.

Incremental Static Regeneration (ISR)

Regenerate the page in the background after a time interval:

// app/blog/[slug]/page.tsx
export const revalidate = 3600  // revalidate every hour
 
export default async function BlogPost({ params }: Props) {
  const { slug } = await params
  const post = await fetch(`https://api.example.com/posts/${slug}`).then(r => r.json())
  return <article>{post.content}</article>
}

ISR gives you the best of both worlds: the performance of static pages with the freshness of dynamic ones.

On-Demand Revalidation

Trigger a revalidation manually (e.g., from a CMS webhook):

// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from 'next/cache'
 
export async function POST(request: Request) {
  const { path } = await request.json()
  revalidatePath(path)  // revalidate a specific path
  return Response.json({ revalidated: true })
}

Server-Side Rendering (SSR)

Render on every request — use when you need always-fresh data:

// Force dynamic rendering (SSR)
export const dynamic = 'force-dynamic'
 
export default async function DashboardPage() {
  // This runs on every request
  const data = await fetch('https://api.example.com/live-data', {
    cache: 'no-store'  // never cache
  }).then(r => r.json())
 
  return <div>{data.currentValue}</div>
}

Use SSR for: user-specific dashboards, real-time data, pages that depend on request headers/cookies.

Client-Side Rendering (CSR)

Standard React behavior — fetch data in the browser:

'use client'
import { useQuery } from '@tanstack/react-query'
 
export function LiveFeed() {
  const { data } = useQuery({
    queryKey: ['feed'],
    queryFn: () => fetch('/api/feed').then(r => r.json()),
    refetchInterval: 5000  // poll every 5 seconds
  })
  return <div>{data?.items.map(/* ... */)}</div>
}

Use CSR for: live data requiring polling, user-specific interactive sections, data that shouldn't be server-rendered.

Rendering Strategy Summary

StrategyWhen Content ChangesUse Case
SSGNever / rarelyMarketing, docs, blog
ISRPeriodically (minutes/hours)News, product catalogs
SSREvery requestDashboards, personalized pages
CSRReal-time / user-triggeredLive feeds, interactive apps

Layouts

Layouts are UI that is shared across routes and persist between navigations — they don't re-render when you navigate to a child route.

Root Layout

The app/layout.tsx is required and wraps your entire app:

// app/layout.tsx
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'
 
const inter = Inter({ subsets: ['latin'] })
 
export const metadata: Metadata = {
  title: 'My App',
  description: 'Built with Next.js',
}
 
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body className={inter.className}>
        <header>Navigation here</header>
        <main>{children}</main>
        <footer>Footer here</footer>
      </body>
    </html>
  )
}

Nested Layouts

Layouts nest automatically based on folder structure:

app/
├── layout.tsx          ← wraps everything (header + footer)
├── page.tsx
└── dashboard/
    ├── layout.tsx      ← wraps only dashboard routes (sidebar)
    ├── page.tsx
    └── settings/
        └── page.tsx    ← gets both layouts applied
// app/dashboard/layout.tsx
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
  return (
    <div className="flex">
      <aside>Sidebar nav</aside>
      <main className="flex-1">{children}</main>
    </div>
  )
}

Special Files

Next.js has several reserved filenames in the App Router:

FilePurpose
page.tsxThe route's UI — makes a route publicly accessible
layout.tsxShared UI that wraps child routes
loading.tsxLoading UI shown while the page is streaming
error.tsxError boundary for the route and its children
not-found.tsxUI for 404 errors
template.tsxLike layout, but re-renders on navigation
route.tsAPI endpoint (Route Handler)

Loading UI (Streaming)

Create loading.tsx alongside your page.tsx:

// app/blog/loading.tsx
export default function Loading() {
  return (
    <div className="animate-pulse space-y-4">
      {[...Array(5)].map((_, i) => (
        <div key={i} className="h-16 bg-gray-200 rounded" />
      ))}
    </div>
  )
}

This uses React Suspense under the hood — the loading UI shows immediately while the page fetches data, then swaps in the real content.

Error Boundaries

// app/blog/error.tsx
'use client'  // error boundaries must be Client Components
 
interface Props {
  error: Error & { digest?: string }
  reset: () => void
}
 
export default function Error({ error, reset }: Props) {
  return (
    <div>
      <h2>Something went wrong!</h2>
      <p>{error.message}</p>
      <button onClick={reset}>Try again</button>
    </div>
  )
}

Route Handlers (API Routes)

Create API endpoints inside your Next.js app using route.ts files:

app/
└── api/
    ├── posts/
    │   ├── route.ts          → GET/POST /api/posts
    │   └── [id]/
    │       └── route.ts      → GET/PUT/DELETE /api/posts/:id
    └── auth/
        └── route.ts          → POST /api/auth

Basic Route Handler

// app/api/posts/route.ts
import { NextRequest } from 'next/server'
 
// GET /api/posts
export async function GET(request: NextRequest) {
  const { searchParams } = new URL(request.url)
  const limit = Number(searchParams.get('limit') ?? '10')
 
  const posts = await db.post.findMany({ take: limit })
  return Response.json(posts)
}
 
// POST /api/posts
export async function POST(request: NextRequest) {
  const body = await request.json()
 
  if (!body.title) {
    return Response.json({ error: 'Title is required' }, { status: 400 })
  }
 
  const post = await db.post.create({ data: body })
  return Response.json(post, { status: 201 })
}

Dynamic Route Handler

// app/api/posts/[id]/route.ts
interface Context {
  params: Promise<{ id: string }>
}
 
export async function GET(request: NextRequest, { params }: Context) {
  const { id } = await params
  const post = await db.post.findUnique({ where: { id } })
 
  if (!post) {
    return Response.json({ error: 'Not found' }, { status: 404 })
  }
 
  return Response.json(post)
}
 
export async function DELETE(request: NextRequest, { params }: Context) {
  const { id } = await params
  await db.post.delete({ where: { id } })
  return new Response(null, { status: 204 })
}

Middleware

Middleware runs before a request is completed — useful for auth, redirects, and A/B testing:

// middleware.ts (at the project root)
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
 
export function middleware(request: NextRequest) {
  const token = request.cookies.get('auth-token')
 
  if (request.nextUrl.pathname.startsWith('/dashboard')) {
    if (!token) {
      return NextResponse.redirect(new URL('/login', request.url))
    }
  }
 
  return NextResponse.next()
}
 
export const config = {
  matcher: ['/dashboard/:path*'],
}

Metadata & SEO

Next.js has a declarative Metadata API — no more manual <head> management.

Static Metadata

// app/about/page.tsx
import type { Metadata } from 'next'
 
export const metadata: Metadata = {
  title: 'About Us',
  description: 'Learn more about our team.',
  openGraph: {
    title: 'About Us',
    description: 'Learn more about our team.',
    images: ['/images/about-og.png'],
  },
  twitter: {
    card: 'summary_large_image',
  },
}
 
export default function AboutPage() {
  return <h1>About</h1>
}

Dynamic Metadata

Generate metadata based on route params:

// app/blog/[slug]/page.tsx
import type { Metadata } from 'next'
 
interface Props {
  params: Promise<{ slug: string }>
}
 
export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params
  const post = await getPost(slug)
 
  return {
    title: post.title,
    description: post.description,
    openGraph: {
      title: post.title,
      description: post.description,
      images: [post.ogImage],
    },
  }
}
 
export default async function BlogPost({ params }: Props) {
  const { slug } = await params
  const post = await getPost(slug)
  return <article>{post.content}</article>
}

Title Templates

Set a title template in the root layout so all pages auto-format their title:

// app/layout.tsx
export const metadata: Metadata = {
  title: {
    template: '%s | My Blog',
    default: 'My Blog',
  },
}
 
// app/about/page.tsx
export const metadata: Metadata = {
  title: 'About',  // Renders as "About | My Blog"
}

The next/image Component

Next.js optimizes images automatically — serving WebP/AVIF, lazy loading, and preventing layout shift:

import Image from 'next/image'
 
export default function Avatar() {
  return (
    <Image
      src="/images/avatar.jpg"
      alt="My avatar"
      width={400}
      height={400}
      priority  // preload above-the-fold images
    />
  )
}

For remote images, whitelist the domain in next.config.ts:

// next.config.ts
const nextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'images.unsplash.com',
      },
    ],
  },
}

Use <Link> for client-side navigation — it prefetches linked pages automatically:

import Link from 'next/link'
 
export function Nav() {
  return (
    <nav>
      <Link href="/">Home</Link>
      <Link href="/blog">Blog</Link>
      <Link href="/about">About</Link>
    </nav>
  )
}

For programmatic navigation:

'use client'
import { useRouter } from 'next/navigation'
 
export function LoginButton() {
  const router = useRouter()
 
  async function handleLogin() {
    await login()
    router.push('/dashboard')  // navigate after action
    router.refresh()           // re-fetch server data
  }
 
  return <button onClick={handleLogin}>Login</button>
}

Environment Variables

Next.js has a built-in environment variable system:

.env.local          # local dev only — never commit this
.env.development    # loaded in `next dev`
.env.production     # loaded in `next build`
.env                # always loaded (defaults)

Server-Only Variables

Variables without a prefix are only available on the server:

# .env.local
DATABASE_URL=postgres://localhost/mydb
JWT_SECRET=super-secret-key
RESEND_API_KEY=re_xxxxxxxx
// app/api/route.ts — server only, fine
const db = createClient(process.env.DATABASE_URL!)

Public Variables

Prefix with NEXT_PUBLIC_ to expose to the browser:

# .env.local
NEXT_PUBLIC_API_URL=https://api.example.com
NEXT_PUBLIC_GA_ID=G-XXXXXXXX
// Any component — available everywhere
const apiUrl = process.env.NEXT_PUBLIC_API_URL

Security rule: Never put secrets in NEXT_PUBLIC_ variables — they ship to every browser.


Static Params for Dynamic Routes

When using SSG with dynamic routes, tell Next.js which slugs to pre-render:

// app/blog/[slug]/page.tsx
 
export async function generateStaticParams() {
  const posts = await getAllPosts()
  return posts.map((post) => ({ slug: post.slug }))
}
 
export default async function BlogPost({ params }: Props) {
  const { slug } = await params
  const post = await getPost(slug)
  return <article>{post.content}</article>
}

At build time, Next.js calls generateStaticParams, builds a page for each slug, and serves them as static files — no server needed at runtime.


Caching in Next.js

Next.js has an aggressive caching system. Understanding it is key to getting correct behavior.

Key fetch options:

// Cache indefinitely (default for Server Components without revalidate)
fetch(url, { cache: 'force-cache' })
 
// Never cache — always fetch fresh
fetch(url, { cache: 'no-store' })
 
// Revalidate every N seconds
fetch(url, { next: { revalidate: 3600 } })
 
// Tag for on-demand revalidation
fetch(url, { next: { tags: ['posts'] } })

Revalidate with tags:

// In a Server Action or Route Handler:
import { revalidateTag } from 'next/cache'
revalidateTag('posts')  // invalidates all fetches tagged 'posts'

Server Actions

Server Actions let you call server-side code directly from Client Components — no Route Handler needed for simple mutations:

// app/actions.ts
'use server'
 
import { revalidatePath } from 'next/cache'
 
export async function createPost(formData: FormData) {
  const title = formData.get('title') as string
  const content = formData.get('content') as string
 
  await db.post.create({ data: { title, content } })
  revalidatePath('/blog')
}

Use in a form:

// app/new-post/page.tsx
import { createPost } from '@/app/actions'
 
export default function NewPostPage() {
  return (
    <form action={createPost}>
      <input name="title" placeholder="Title" />
      <textarea name="content" placeholder="Content" />
      <button type="submit">Create Post</button>
    </form>
  )
}

Or call from a Client Component:

'use client'
import { createPost } from '@/app/actions'
import { useTransition } from 'react'
 
export function PostForm() {
  const [isPending, startTransition] = useTransition()
 
  function handleSubmit(formData: FormData) {
    startTransition(async () => {
      await createPost(formData)
    })
  }
 
  return (
    <form action={handleSubmit}>
      <input name="title" />
      <button type="submit" disabled={isPending}>
        {isPending ? 'Creating...' : 'Create Post'}
      </button>
    </form>
  )
}

Putting It All Together: A Blog App

Here's the architecture of a complete blog app using everything covered in this post:

File structure:

app/
├── layout.tsx               # Root layout: fonts, providers
├── page.tsx                 # Home: recent posts (ISR)
├── blog/
│   ├── layout.tsx           # Blog layout: sidebar, breadcrumb
│   ├── page.tsx             # All posts (ISR 1 hour)
│   ├── loading.tsx          # Skeleton while loading
│   └── [slug]/
│       ├── page.tsx         # Post content (SSG)
│       └── not-found.tsx    # Post not found
├── dashboard/
│   ├── layout.tsx           # Dashboard layout: sidebar nav
│   └── page.tsx             # User stats (SSR, auth-protected)
└── api/
    └── posts/
        ├── route.ts         # GET list, POST create
        └── [id]/
            └── route.ts     # GET, PUT, DELETE by id

Common Mistakes to Avoid

1. Adding 'use client' too high up

// ❌ Wrong: entire page is a Client Component
'use client'
export default function BlogPage() {
  const posts = // can't fetch server-side anymore
  return <div>...</div>
}
 
// ✅ Right: only the interactive part is a Client Component
export default async function BlogPage() {  // Server Component
  const posts = await getPosts()
  return (
    <div>
      {posts.map(p => <PostCard key={p.id} post={p} />)}
      <SearchBar />  {/* Client Component for interactivity */}
    </div>
  )
}

2. Using useEffect for data fetching in Server Components

// ❌ Wrong: unnecessary client-side fetching
'use client'
export default function BlogPage() {
  const [posts, setPosts] = useState([])
  useEffect(() => {
    fetch('/api/posts').then(r => r.json()).then(setPosts)
  }, [])
  return <div>{posts.map(/*...*/)}</div>
}
 
// ✅ Right: fetch directly in Server Component
export default async function BlogPage() {
  const posts = await getPosts()  // runs on server, no bundle impact
  return <div>{posts.map(/*...*/)}</div>
}

3. Forgetting to await params

// ❌ Wrong in Next.js 15
export default function Page({ params }) {
  const { slug } = params  // params is a Promise in Next.js 15
}
 
// ✅ Right
export default async function Page({ params }) {
  const { slug } = await params
}

4. Putting secrets in NEXT_PUBLIC_ variables

# ❌ Wrong: ships to the browser
NEXT_PUBLIC_DATABASE_URL=postgres://...
NEXT_PUBLIC_JWT_SECRET=my-secret
 
# ✅ Right: server-only
DATABASE_URL=postgres://...
JWT_SECRET=my-secret

Practice Exercises

Exercise 1: Create a Next.js blog with:

  • Home page listing 3 recent posts (SSG with ISR)
  • Blog listing page with all posts
  • Individual post pages with generateStaticParams
  • Dynamic metadata per post

Exercise 2: Add a newsletter API:

  • POST /api/subscribe that saves an email to a JSON file
  • A subscribe form (Client Component)
  • A Server Action version of the same form

Exercise 3: Build a protected dashboard:

  • Middleware that redirects unauthenticated users to /login
  • A simple login form that sets a cookie
  • A dashboard page that reads the cookie and shows user info
  • A logout button that clears the cookie

Exercise 4: Nested layouts challenge:

  • Create a /docs section with its own layout (sidebar navigation)
  • Create /docs/getting-started, /docs/api, and /docs/examples pages
  • Add loading.tsx and error.tsx to the docs route

Summary

Next.js transforms React from a UI library into a complete full-stack framework. Here's what you've learned:

File-based routing: folders in app/ become URL paths
Server vs Client Components: server by default, 'use client' for interactivity
Rendering strategies: SSG, ISR, SSR, and CSR — choose per page
Layouts: shared UI that persists across navigation
Special files: loading.tsx, error.tsx, not-found.tsx
Route Handlers: API endpoints colocated with your UI
Metadata API: declarative SEO without manual <head>
Environment variables: server-only vs NEXT_PUBLIC_
Caching: force-cache, no-store, revalidate and tags
Server Actions: call server code from Client Components without Route Handlers

The mental model shift — Server Components by default, Client Components only where needed — is the key to writing performant, secure Next.js applications.


Series: React.js & Next.js Learning Roadmap
Previous: Phase 2: React Ecosystem
Next: Deep Dive: React Hooks Mastery (Coming Soon)

📬 Subscribe to Newsletter

Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.

We respect your privacy. Unsubscribe at any time.

💬 Comments

Sign in to leave a comment

We'll never post without your permission.