The App Router is a fundamentally different mental model from the Pages Router. Once the server-vs-client component distinction clicks, everything else follows. Here's the complete picture.
File System Routing
The app/ directory defines routes through folder structure:
app/
├── layout.tsx → / (root layout, wraps everything)
├── page.tsx → / (home page)
├── globals.css
├── templates/
│ ├── layout.tsx → /templates (templates layout)
│ ├── page.tsx → /templates
│ └── [id]/
│ └── page.tsx → /templates/123
├── blog/
│ ├── page.tsx → /blog
│ └── [slug]/
│ └── page.tsx → /blog/my-post
└── api/
└── templates/
└── route.ts → /api/templates (API route)
Special files:
page.tsx— the visible UI for a routelayout.tsx— shared UI that wraps child pagesloading.tsx— shown while the page is loading (Suspense boundary)error.tsx— error boundary for the routenot-found.tsx— 404 UI
Server Components — The Default
Every component in app/ is a Server Component by default. They can be async, fetch data directly, and never ship JavaScript to the browser:
// app/templates/page.tsx — Server Component
interface Template {
id: number
name: string
framework: string
price: number
}
// No 'use client' — this runs on the server at build/request time
export default async function TemplatesPage() {
// fetch runs server-side — no CORS, no loading state in browser
const templates = await fetch('https://api.lettstartdesign.com/templates', {
next: { revalidate: 3600 } // cache for 1 hour
}).then(r => r.json()) as Template[]
return (
<main className="container py-4">
<h1 className="fw-bold mb-4">Templates</h1>
<div className="row g-4">
{templates.map(t => (
<div key={t.id} className="col-md-4">
<div className="card border-0 shadow-sm h-100">
<div className="card-body p-4">
<span className="badge mb-2" style={{ background: '#fd4766' }}>{t.framework}</span>
<h5 className="fw-bold">{t.name}</h5>
<p className="fw-bold mt-auto" style={{ color: '#fd4766' }}>${t.price}</p>
</div>
</div>
</div>
))}
</div>
</main>
)
}
Client Components — When You Need Interactivity
Add 'use client' at the top of any component that uses state, effects, or event listeners:
// app/templates/TemplateFilter.tsx — Client Component
'use client'
import { useState } from 'react'
export default function TemplateFilter({
frameworks,
onFilter
}: {
frameworks: string[]
onFilter: (fw: string) => void
}) {
const [active, setActive] = useState('All')
function select(fw: string) {
setActive(fw)
onFilter(fw)
}
return (
<div className="d-flex gap-2 flex-wrap mb-4">
{['All', ...frameworks].map(fw => (
<button
key={fw}
onClick={() => select(fw)}
className={`btn btn-sm ${active === fw ? 'text-white' : 'btn-outline-secondary'}`}
style={active === fw ? { background: '#fd4766', border: 'none' } : {}}
>
{fw}
</button>
))}
</div>
)
}
Layouts — Shared UI Across Routes
// app/layout.tsx — Root layout (required)
import type { Metadata } from 'next'
import './globals.css'
import 'bootstrap/dist/css/bootstrap.min.css'
export const metadata: Metadata = {
metadataBase: new URL('https://bootstrapplanet.com'),
title: { default: 'BootstrapPlanet', template: '%s | BootstrapPlanet' },
description: 'Free Bootstrap 5 components, Angular and React examples.',
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}
// app/templates/layout.tsx — Templates section layout
export default function TemplatesLayout({ children }: { children: React.ReactNode }) {
return (
<div>
<nav className="navbar navbar-expand-lg bg-dark navbar-dark px-4">
<a className="navbar-brand fw-bold" href="/">LettStart</a>
<div className="navbar-nav ms-auto">
<a className="nav-link" href="/templates">Templates</a>
<a className="nav-link" href="/angular">Angular</a>
</div>
</nav>
<main>{children}</main>
</div>
)
}
Layouts persist between navigations — the navbar above doesn't re-mount when navigating from /templates to /templates/123.
Dynamic Routes
// app/templates/[id]/page.tsx
interface Template { id: number; name: string; framework: string; description: string }
// generateStaticParams — pre-generates all template pages at build time
export async function generateStaticParams() {
const templates = await fetch('/api/templates').then(r => r.json())
return templates.map((t: Template) => ({ id: String(t.id) }))
}
// generateMetadata — dynamic SEO metadata per template
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const template = await fetch(`/api/templates/${id}`).then(r => r.json())
return {
title: template.name,
description: template.description,
alternates: { canonical: `https://bootstrapplanet.com/templates/${id}` }
}
}
export default async function TemplatePage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const template: Template = await fetch(`/api/templates/${id}`).then(r => r.json())
return (
<main className="container py-4">
<h1 className="fw-bold">{template.name}</h1>
<span className="badge" style={{ background: '#fd4766' }}>{template.framework}</span>
<p className="mt-3">{template.description}</p>
</main>
)
}
Loading and Error States
// app/templates/loading.tsx — shown during page load (automatic Suspense)
export default function Loading() {
return (
<div className="container py-4">
<div className="row g-4">
{[1, 2, 3, 4, 5, 6].map(i => (
<div key={i} className="col-md-4">
<div className="card border-0 shadow-sm" style={{ height: '180px' }}>
<div className="card-body p-4">
<div className="placeholder-glow">
<span className="placeholder col-4 mb-3" style={{ background: '#eee', height: '20px', display: 'block', borderRadius: '4px' }}></span>
<span className="placeholder col-8" style={{ background: '#eee', height: '16px', display: 'block', borderRadius: '4px' }}></span>
</div>
</div>
</div>
</div>
))}
</div>
</div>
)
}
// app/templates/error.tsx — must be a Client Component
'use client'
export default function Error({
error, reset
}: {
error: Error
reset: () => void
}) {
return (
<div className="container py-4">
<div className="alert alert-danger d-flex align-items-center gap-3">
<span>⚠️</span>
<div>
<strong>Something went wrong</strong>
<p className="mb-0 small">{error.message}</p>
</div>
<button className="btn btn-sm btn-danger ms-auto" onClick={reset}>Retry</button>
</div>
</div>
)
}
API Routes
// app/api/templates/route.ts
import { NextRequest, NextResponse } from 'next/server'
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const framework = searchParams.get('framework')
const templates = [
{ id: 1, name: 'Marvel Dashboard', framework: 'Angular', price: 49 },
{ id: 2, name: 'PORTO', framework: 'Bootstrap', price: 29 },
].filter(t => !framework || t.framework === framework)
return NextResponse.json(templates)
}
export async function POST(request: NextRequest) {
const body = await request.json()
// create template...
return NextResponse.json({ id: 3, ...body }, { status: 201 })
}
Key Concepts Summary
| Concept | Rule |
|---|---|
| Server Component | Default — async, no browser APIs, no useState/useEffect |
| Client Component | Add 'use client' — has state, events, browser APIs |
| Layout | Wraps child pages, persists between navigations |
loading.tsx | Auto Suspense fallback while page loads |
error.tsx | Error boundary — must be 'use client' |
generateStaticParams | Pre-renders all dynamic routes at build time |
generateMetadata | Per-page SEO metadata — title, description, canonical |
next: { revalidate } | ISR — rebuild cache after N seconds |
Frequently Asked Questions
Need a Full Bootstrap 5 Admin Dashboard?
Get a complete Angular 21 + Bootstrap 5 dashboard with 50+ components — built by the same team behind BootstrapPlanet.
Browse Templates →Use code FIRST30 for 30% off your first purchase.