Next.js App Router — Modern Patterns for 2026
App Router vs Pages Router — why change
The old pages/ directory approach was fine but bolted routing atop client-side React. App Router makes the server first-class:
| Feature | Pages Router | App Router |
|---|---|---|
| Server/client boundary | getServerSideProps / getStaticProps at page level | Per-component via 'use client' |
| Layouts | Manually composed | Native layout files |
| Data fetching | Hook-based mostly | fetch() in Server Components |
| Streaming | Limited | Built-in via <Suspense> |
| Forms | onSubmit handler | Server Actions (no fetch needed) |
| Metadata | <Head> component | metadata export + generateMetadata |
For new projects in 2026: always App Router. Pages Router receives maintenance but no new features.
Project layout
app/
├── layout.tsx # root layout (wraps everything)
├── page.tsx # home page
├── loading.tsx # shown while page is loading
├── error.tsx # shown on errors
├── not-found.tsx # 404
├── (marketing)/ # route group — doesn't add to URL
│ ├── about/page.tsx
│ └── pricing/page.tsx
├── (app)/ # route group for authenticated routes
│ ├── layout.tsx # different layout (e.g. with sidebar)
│ ├── dashboard/page.tsx
│ └── settings/page.tsx
├── api/ # route handlers (former API routes)
│ └── users/route.ts
├── blog/
│ ├── page.tsx # /blog
│ └── [slug]/page.tsx # /blog/<slug>Parentheses (group) don't affect URL. Brackets [slug] = dynamic segment.
Server Components by default
Every component is server-rendered unless marked 'use client'.
Server Component (default):
// app/blog/page.tsx
import { db } from '@/lib/db';
export default async function BlogList() {
const posts = await db.post.findMany();
return (
<ul>
{posts.map(p => <li key={p.id}>{p.title}</li>)}
</ul>
);
}- Runs on server
- Can directly query DB, use secrets
- Ships zero JS to client for this component
- Can't use
useState,useEffect, browser APIs
Client Component:
// app/components/LikeButton.tsx
'use client';
import { useState } from 'react';
export function LikeButton({ postId }: { postId: string }) {
const [liked, setLiked] = useState(false);
return (
<button onClick={() => setLiked(!liked)}>
{liked ? '❤️' : '🤍'}
</button>
);
}- Runs on server (for SSR) AND client (for interactivity)
- Needs
'use client'directive at top - Use for anything interactive
Rule: ship as much as Server Components; use Client Components only when needed.
Data fetching
Built-in fetch in Server Components with caching:
export default async function Page() {
// Static — cached indefinitely
const staticData = await fetch('https://api.example.com/static').then(r => r.json());
// Revalidate every 60s
const dynamicData = await fetch('https://api.example.com/news', {
next: { revalidate: 60 },
}).then(r => r.json());
// Always fresh
const liveData = await fetch('https://api.example.com/stock', {
cache: 'no-store',
}).then(r => r.json());
return <article>...</article>;
}Next.js dedupes concurrent identical fetches per render. No extra query.
Server Actions — mutations without fetch
// app/post/[id]/page.tsx
import { revalidatePath } from 'next/cache';
async function addComment(postId: string, formData: FormData) {
'use server';
const text = formData.get('text') as string;
await db.comment.create({ data: { postId, text } });
revalidatePath(`/post/${postId}`);
}
export default async function Post({ params }: { params: { id: string } }) {
return (
<form action={addComment.bind(null, params.id)}>
<textarea name="text" />
<button type="submit">Comment</button>
</form>
);
}- Function executes on server
- No fetch/API route needed
revalidatePathrefreshes cached data- Progressive enhancement — works without JavaScript
Streaming with Suspense
Long-loading components don't block the page:
import { Suspense } from 'react';
export default function Dashboard() {
return (
<div>
<Header />
<Suspense fallback={<Skeleton />}>
<SlowReport />
</Suspense>
<Suspense fallback={<Skeleton />}>
<EvenSlowerReport />
</Suspense>
</div>
);
}Header shows instantly; reports stream in when ready. Shell is fast; content fills in.
Partial Prerendering (2026 stable)
Combines static + dynamic in one route. Static parts ship from CDN; dynamic parts stream from origin.
// app/product/[id]/page.tsx
export const experimental_ppr = true;
import { Suspense } from 'react';
export default function Product({ params }) {
return (
<div>
{/* Static — prerendered */}
<ProductDescription id={params.id} />
<Suspense fallback={<div>Loading cart...</div>}>
{/* Dynamic — user-specific, rendered per request */}
<UserCart />
</Suspense>
</div>
);
}Best of SSG + SSR.
Middleware — edge logic
middleware.ts at project root — runs before every matched request:
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
// Redirect non-www to www
if (!request.nextUrl.host.startsWith('www.')) {
return NextResponse.redirect(new URL(`https://www.${request.nextUrl.host}${request.nextUrl.pathname}`));
}
// Auth gate
if (request.nextUrl.pathname.startsWith('/dashboard')) {
const token = request.cookies.get('session');
if (!token) {
return NextResponse.redirect(new URL('/login', request.url));
}
}
// A/B test
const bucket = request.cookies.get('ab')?.value || (Math.random() > 0.5 ? 'a' : 'b');
const response = NextResponse.next();
response.cookies.set('ab', bucket);
return response;
}
export const config = {
matcher: ['/((?!api|_next|favicon.ico).*)'],
};Runs at the edge. Use for redirects, auth, A/B tests, geolocation routing.
Metadata API (SEO)
// app/blog/[slug]/page.tsx
import type { Metadata } from 'next';
export async function generateMetadata(
{ params }: { params: { slug: string } }
): Promise<Metadata> {
const post = await getPost(params.slug);
return {
title: `${post.title} | Your Blog`,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
images: [post.coverImage],
},
twitter: { card: 'summary_large_image' },
};
}Next.js generates <head> tags automatically.
Static vs ISR vs SSR vs PPR
// Static (at build time) — default behaviour
export default async function Page() {
const data = await fetch('...'); return <div>{data}</div>;
}
// ISR (Incremental Static Regeneration)
export const revalidate = 60; // seconds
// SSR (every request fresh)
export const dynamic = 'force-dynamic';
// PPR (partial, as above)
export const experimental_ppr = true;Pick based on data freshness:
| Data freshness | Strategy |
|---|---|
| Never changes (docs, landing page) | Static |
| Changes hourly | ISR with revalidate: 3600 |
| Per-user (dashboard) | SSR |
| Mixed (user header + static product) | PPR |
Deployment on DomainIndia
VPS (recommended for self-hosting):
npm run build # produces .next/ build output
npm install --production
node_modules/.bin/next start -p 3000systemd + nginx reverse proxy. See our Go/Rust deployment articles for pattern.
Shared cPanel:
Use next build && next export for static export (no dynamic features). Upload out/ to public_html.
For dynamic Next.js: needs Node.js app support on cPanel. Works for small apps.
Vercel: simplest but vendor-locked and $$ at scale.
Cloudflare Workers: runs Next.js via @cloudflare/next-on-pages. Good for global distribution.
Common pitfalls
FAQ
App Router, unquestionably. Pages is legacy.
Next.js — most features, biggest ecosystem, enterprise-ready. Remix — similar features, smaller ecosystem, different philosophy. Astro — content-focused (blogs, docs), per-component framework choice. Pick Next for apps, Astro for content-heavy sites.
Static export yes. Dynamic rendering — needs Node.js app support. VPS is simpler for serious use.
For internal data fetching — often yes. For mobile apps or third-party integrations, you still need REST/GraphQL APIs.
Actions for forms + mutations triggered by user. API routes for webhooks, external consumers, GraphQL.
Deploy Next.js on a DomainIndia VPS — no vendor lock-in. Start with VPS