Next.js App Router Architecture
Help users make sound architectural decisions in Next.js App Router projects using Tailwind CSS for styling.
Server vs Client Components
The most important architectural decision in modern Next.js is understanding the server/client boundary.
Default to Server Components
Every component is a Server Component unless it opts in with "use client". Keep components on the server when they:
- Fetch data (database queries, API calls)
- Access backend resources directly
- Keep sensitive logic (tokens, API keys) off the client
- Render large dependencies that don't need interactivity
Use Client Components when you need
- Event handlers (onClick, onChange, onSubmit)
- State (useState, useReducer)
- Effects (useEffect, useLayoutEffect)
- Browser APIs (window, document, localStorage)
- Custom hooks that depend on state or effects
The boundary pattern
Push "use client" as far down the tree as possible. Instead of making an entire page a client component because one button needs state, extract just the interactive part:
app/
dashboard/
page.tsx ← Server Component (fetches data)
components/
stats-grid.tsx ← Server Component (renders data)
filter-bar.tsx ← Client Component (has state)
Example — composing server and client:
// app/dashboard/page.tsx (Server Component)
import { FilterBar } from './components/filter-bar'
import { StatsGrid } from './components/stats-grid'
export default async function DashboardPage() {
const stats = await getStats()
return (
<div className="p-6">
<FilterBar />
<StatsGrid data={stats} />
</div>
)
}
// app/dashboard/components/filter-bar.tsx
"use client"
import { useState } from 'react'
export function FilterBar() {
const [filter, setFilter] = useState('all')
return (
<div className="flex gap-2 mb-4">
{['all', 'active', 'archived'].map(f => (
<button
key={f}
=> setFilter(f)}
className={`px-3 py-1 rounded ${filter === f ? 'bg-blue-600 text-white' : 'bg-gray-100'}`}
>
{f}
</button>
))}
</div>
)
}
Project Structure
Organize by feature/route rather than by type. This scales better as projects grow because related files stay together.
app/
(marketing)/ ← Route group (no URL segment)
page.tsx ← Landing page
pricing/page.tsx
about/page.tsx
layout.tsx ← Marketing-specific layout
(app)/ ← Route group for authenticated app
dashboard/
page.tsx
loading.tsx
components/ ← Route-specific components
settings/
page.tsx
layout.tsx ← App layout with sidebar/nav
api/
[...route]/route.ts ← API routes
layout.tsx ← Root layout
globals.css
components/ ← Shared components used across routes
ui/ ← Generic UI primitives (button, card, input)
forms/ ← Reusable form components
lib/
db.ts ← Database client
auth.ts ← Auth utilities
utils.ts ← General helpers
types/
index.ts ← Shared TypeScript types
Route groups
Use (groupName) to organize routes without affecting the URL. Common patterns:
(marketing)vs(app)— different layouts for public/authenticated sections(auth)— group login/register/forgot-password under one layout
Colocation
Keep route-specific components, utils, and types alongside the route. Only promote to top-level components/ or lib/ when something is genuinely shared across multiple routes.
Layouts and Templates
Layouts persist across navigations
// app/(app)/layout.tsx
export default function AppLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex h-screen">
<Sidebar />
<main className="flex-1 overflow-auto">{children}</main>
</div>
)
}
Layouts don't remount on navigation — their state is preserved. Use this for persistent UI like sidebars, navbars, and tab bars.
Templates remount on every navigation
Use template.tsx instead of layout.tsx when you need fresh state on each navigation (e.g., enter/exit animations, per-page analytics logging).
Data Fetching
Server Components — fetch directly
// app/products/page.tsx
async function getProducts() {
const res = await fetch('https://api.example.com/products', {
next: { revalidate: 3600 } // cache for 1 hour
})
return res.json()
}
export default async function ProductsPage() {
const products = await getProducts()
return <ProductList products={products} />
}
Caching strategies
{ cache: 'force-cache' }— cache indefinitely (default for GET in production){ cache: 'no-store' }— always fresh, never cached{ next: { revalidate: N } }— cache for N seconds, then revalidate in the background{ next: { tags: ['products'] } }— tag-based on-demand revalidation withrevalidateTag('products')
Server Actions for mutations
// app/products/actions.ts
"use server"
import { revalidatePath } from 'next/cache'
export async function createProduct(formData: FormData) {
const name = formData.get('name') as string
await db.product.create({ data: { name } })
revalidatePath('/products')
}
Use Server Actions for form submissions and mutations. They run on the server, can access your database directly, and handle revalidation.
Parallel data fetching
When a page needs multiple independent data sources, fetch them in parallel rather than sequentially:
export default async function DashboardPage() {
const [stats, activity, notifications] = await Promise.all([
getStats(),
getRecentActivity(),
getNotifications(),
])
return (
<>
<StatsGrid data={stats} />
<ActivityFeed items={activity} />
<NotificationList notifications={notifications} />
</>
)
}
Loading and Error States
loading.tsx — instant loading UI
// app/dashboard/loading.tsx
export default function DashboardLoading() {
return (
<div className="animate-pulse space-y-4 p-6">
<div className="h-8 w-48 bg-gray-200 rounded" />
<div className="grid grid-cols-3 gap-4">
{[1, 2, 3].map(i => (
<div key={i} className="h-32 bg-gray-200 rounded-lg" />
))}
</div>
</div>
)
}
Next.js automatically wraps your page in a <Suspense> boundary using loading.tsx, so users see the skeleton immediately while data loads.
error.tsx — graceful error handling
// app/dashboard/error.tsx
"use client"
export default function DashboardError({
error,
reset,
}: {
error: Error
reset: () => void
}) {
return (
<div className="flex flex-col items-center justify-center p-6">
<h2 className="text-xl font-semibold mb-2">Something went wrong</h2>
<p className="text-gray-500 mb-4">{error.message}</p>
<button
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
Try again
</button>
</div>
)
}
not-found.tsx — custom 404 per route
Place not-found.tsx in any route segment to customize the 404 page for that section.
Middleware
Use middleware for request-level logic that runs before any route is matched:
// middleware.ts (root of project)
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') && !token) {
return NextResponse.redirect(new URL('/login', request.url))
}
return NextResponse.next()
}
export const config = {
matcher: ['/dashboard/:path*', '/settings/:path*'],
}
Common middleware use cases: authentication checks, redirects, A/B testing, geolocation-based routing, request logging.
Dynamic Routes
app/
products/
[id]/
page.tsx ← /products/123
[...slug]/
page.tsx ← /products/a/b/c (catch-all)
[[...slug]]/
page.tsx ← /products or /products/a/b (optional catch-all)
generateStaticParams for static generation
export async function generateStaticParams() {
const products = await getProducts()
return products.map(p => ({ id: p.id }))
}
This pre-renders pages at build time for known params while falling back to dynamic rendering for unknown ones.
Metadata
// Static metadata
export const metadata = {
title: 'Dashboard',
description: 'View your analytics dashboard',
}
// Dynamic metadata
export async function generateMetadata({ params }: { params: { id: string } }) {
const product = await getProduct(params.id)
return {
title: product.name,
openGraph: { images: [product.image] },
}
}
Use the root layout for global metadata (site name, default OG image) and individual pages for route-specific metadata.