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:
| Feature | React alone | Next.js |
|---|---|---|
| Routing | You set up React Router | Built-in file-based routing |
| Data fetching | useEffect / TanStack Query on client | Server-side fetch, cached, colocated |
| API endpoints | Need a separate backend | Route Handlers in the same project |
| SEO | Manual <head> management | Declarative Metadata API |
| Performance | Manual code splitting | Automatic with Server Components |
| Image optimization | Manual | Built-in <Image> component |
Project Setup
Bootstrap a new Next.js 15 project:
npx create-next-app@latest my-appThe CLI will ask:
- TypeScript? → Yes
- ESLint? → Yes
- Tailwind CSS? → Yes (recommended)
src/directory? → Your preference (this guide uses rootapp/)- 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.jsonFile-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/settingsDynamic 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 → /dashboardThis 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
async—awaitdata directly, nouseEffectneeded - 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
| Scenario | Component Type |
|---|---|
| Fetch data from API or DB | Server Component |
| Display static or server-fetched content | Server Component |
| Add interactivity (click, hover, etc.) | Client Component |
Use useState, useEffect | Client Component |
Access window, localStorage | Client Component |
| Subscribe to browser events | Client Component |
| Keep secrets out of the bundle | Server 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
| Strategy | When Content Changes | Use Case |
|---|---|---|
| SSG | Never / rarely | Marketing, docs, blog |
| ISR | Periodically (minutes/hours) | News, product catalogs |
| SSR | Every request | Dashboards, personalized pages |
| CSR | Real-time / user-triggered | Live 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:
| File | Purpose |
|---|---|
page.tsx | The route's UI — makes a route publicly accessible |
layout.tsx | Shared UI that wraps child routes |
loading.tsx | Loading UI shown while the page is streaming |
error.tsx | Error boundary for the route and its children |
not-found.tsx | UI for 404 errors |
template.tsx | Like layout, but re-renders on navigation |
route.ts | API 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/authBasic 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',
},
],
},
}The next/link Component
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_URLSecurity 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 idCommon 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-secretPractice 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/subscribethat 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
/docssection with its own layout (sidebar navigation) - Create
/docs/getting-started,/docs/api, and/docs/examplespages - Add
loading.tsxanderror.tsxto 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.