Next.js App Router 2026
Version (2026)
- Next.js:
15.x(stable) /16.x(latest with Cache Components, PPR) - React:
19.x— Server Components stable - Tailwind:
v4 - TypeScript: required
npx create-next-app@latest dashboard --typescript --tailwind --app --src-dir
App Router — Core Mental Model
app/
├── layout.tsx # Root layout (required — wraps all pages)
├── page.tsx # Home page
├── (auth)/ # Route group — no URL segment
│ └── login/
│ └── page.tsx
├── dashboard/
│ ├── layout.tsx # Nested layout (dashboard shell)
│ ├── page.tsx # /dashboard
│ └── feed/
│ └── page.tsx # /dashboard/feed
└── api/
└── browser/
└── route.ts # Route handler (replaces pages/api/)
Rule: Folders = routes. Files = behavior.
page.tsx— renders the routelayout.tsx— persistent wrapper, doesn't re-render on child navigationloading.tsx— Suspense fallback while data loadserror.tsx— error boundaryroute.ts— API endpoint (GET, POST, etc.)
Server vs Client Components
Server Components (default in 2026)
- Run on server. Zero JS sent to client.
- Can
async/awaitdirectly — no useEffect, no useState - Can access secrets, DB, filesystem
- Cannot: use hooks, event handlers, browser APIs
// app/dashboard/feed/page.tsx — Server Component by default
export default async function FeedPage() {
// Direct async data fetch — no useEffect needed
const feed = await fetch("http://api:8000/browser/feed", {
cache: "no-store", // always fresh (dynamic route)
}).then(r => r.json())
return (
<div>
{feed.map((post: any) => (
<PostCard key={post.id} post={post} />
))}
</div>
)
}
Client Components
// 'use client' MUST be first line
"use client"
import { useState } from "react"
export function ApproveButton({ draftId }: { draftId: string }) {
const [loading, setLoading] = useState(false)
async function approve() {
setLoading(true)
await fetch(`/api/browser/approve/${draftId}`, { method: "POST" })
setLoading(false)
}
return (
<button onClick={approve} disabled={loading}>
{loading ? "Posting..." : "Approve"}
</button>
)
}
Pattern: Server component renders the page, imports client components for interactive islands.
Fetch Caching (2026 — Explicit)
// Always fresh — for live data
const data = await fetch(url, { cache: "no-store" })
// Cached with revalidation every 60s — for semi-static
const data = await fetch(url, { next: { revalidate: 60 } })
// Fully static — for config/reference data
const data = await fetch(url) // default: cached
Route Handlers (API endpoints)
// app/api/browser/approve/[id]/route.ts
import { NextRequest, NextResponse } from "next/server"
export async function POST(
req: NextRequest,
{ params }: { params: { id: string } }
) {
const res = await fetch(`http://api:8000/browser/approve/${params.id}`, {
method: "POST",
})
const data = await res.json()
return NextResponse.json(data)
}
Server Actions (form + mutation without API route)
// app/dashboard/compose/page.tsx
export default function ComposePage() {
async function createDraft(formData: FormData) {
"use server" // marks this function as server action
const content = formData.get("content") as string
await fetch("http://api:8000/drafts", {
method: "POST",
body: JSON.stringify({ content }),
headers: { "Content-Type": "application/json" },
})
}
return (
<form action={createDraft}>
<textarea name="content" placeholder="What's on your mind?" />
<button type="submit">Save Draft</button>
</form>
)
}
Layouts (Persistent Shell)
// app/dashboard/layout.tsx
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex h-screen">
<nav className="w-64 bg-gray-900">
{/* Sidebar — never re-renders between dashboard pages */}
</nav>
<main className="flex-1 overflow-auto">
{children}
</main>
</div>
)
}
Tailwind v4 (2026)
# v4 is CSS-first — no tailwind.config.js needed for basic use
uv add --dev @tailwindcss/vite # or use Next.js built-in support
Key v4 changes vs v3:
- Config in CSS
@themeblock, nottailwind.config.js - Automatic content detection (no
content: []array needed) @import "tailwindcss"replaces@tailwind base/components/utilities
/* app/globals.css */
@import "tailwindcss";
@theme {
--color-brand: #6366f1;
--font-sans: "Inter", sans-serif;
}
Environment Variables
# .env.local (server-only, never sent to client)
API_URL=http://api:8000
# .env.local (accessible in client — must prefix NEXT_PUBLIC_)
NEXT_PUBLIC_WS_URL=ws://your-vps:8000/ws/feed
// Server component — can use server-only env
const apiUrl = process.env.API_URL
// Client component — must use NEXT_PUBLIC_
const wsUrl = process.env.NEXT_PUBLIC_WS_URL
Anti-Patterns
// ❌ Pages Router — dead in 2026 for new projects
// pages/index.tsx
export async function getServerSideProps() { ... } // NEVER
// ❌ useEffect for data fetching in 2026
useEffect(() => {
fetch("/api/feed").then(...) // use Server Component async fetch instead
}, [])
// ❌ 'use client' everywhere — kills perf
// Only add 'use client' when you actually need hooks/events
// ❌ API routes in pages/api/
// pages/api/browser.ts — use app/api/ route handlers instead
WebSocket in Client Component
"use client"
import { useEffect, useState } from "react"
export function LiveFeed() {
const [posts, setPosts] = useState<any[]>([])
useEffect(() => {
const ws = new WebSocket(process.env.NEXT_PUBLIC_WS_URL!)
ws.onmessage = (e) => {
const post = JSON.parse(e.data)
setPosts(prev => [post, ...prev])
}
return () => ws.close()
}, [])
return <div>{posts.map(p => <div key={p.id}>{p.content}</div>)}</div>
}
References
- Next.js App Router docs: https://nextjs.org/docs/app
- Server/Client Components: https://nextjs.org/docs/app/getting-started/server-and-client-components
- Production checklist: https://nextjs.org/docs/app/guides/production-checklist
- Tailwind v4: https://tailwindcss.com/docs/v4-beta