Advanced
This advanced tutorial covers expert-level Next.js patterns through 25 heavily annotated examples. Each example maintains 1-2.25 comment lines per code line to ensure deep understanding.
Prerequisites
Before starting, ensure you understand:
- Intermediate Next.js patterns (Server Actions, caching, route organization, authentication)
- React advanced patterns (Suspense, Error Boundaries, Context)
- TypeScript advanced features (generics, conditional types, utility types)
- HTTP caching concepts (ETags, Cache-Control, stale-while-revalidate)
Group 1: Static Site Generation (SSG) & ISR
Example 51: Static Site Generation with generateStaticParams
Use generateStaticParams to pre-render dynamic routes at build time. Creates static HTML for all specified parameter combinations.
// app/products/[id]/page.tsx
// => generateStaticParams runs at build time
// => Returns array of params to pre-render
export async function generateStaticParams() {
// => Fetch all product IDs from database/API
const products = await fetch('https://api.example.com/products').then(res =>
res.json()
);
// => products is [{ id: "1" }, { id: "2" }, { id: "3" }]
// => Return array of params objects
return products.map((product: any) => ({
id: product.id.toString(), // => Must return { id: string }
}));
// => Next.js pre-renders /products/1, /products/2, /products/3
}
// => Page component receives params
export default async function ProductPage({
params,
}: {
params: { id: string };
}) {
// => For pre-rendered routes, this runs at build time
// => For dynamic routes (not in generateStaticParams), runs at request time
const product = await fetch(`https://api.example.com/products/${params.id}`).then(
res => res.json()
);
// => product is { id: "1", name: "Murabaha", description: "..." }
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
</div>
);
}
// => Optional: enable static export (fully static site)
export const dynamicParams = false;
// => dynamicParams = false: 404 for routes not in generateStaticParams
// => dynamicParams = true (default): generate on-demand for new routes
Key Takeaway: Use generateStaticParams to pre-render dynamic routes at build time. Combines static generation benefits with dynamic routing flexibility.
Expected Output: Build generates static HTML for /products/1, /products/2, /products/3. Instant page loads, no server rendering needed.
Common Pitfalls: Forgetting to return array of params objects (wrong format), or not setting dynamicParams correctly (unexpected 404s or slow pages).
Example 52: Incremental Static Regeneration (ISR)
Combine static generation with time-based revalidation. Serve stale content instantly while regenerating in background.
// app/blog/[slug]/page.tsx
// => Pre-render blog posts at build time
export async function generateStaticParams() {
const posts = await fetch('https://cms.example.com/posts').then(res =>
res.json()
);
return posts.map((post: any) => ({
slug: post.slug,
}));
}
// => Page with ISR configuration
export default async function BlogPostPage({
params,
}: {
params: { slug: string };
}) {
// => Fetch post data
const post = await fetch(`https://cms.example.com/posts/${params.slug}`, {
next: { revalidate: 300 }, // => Revalidate every 5 minutes
}).then(res => res.json());
// => First request after 5 minutes: serves stale, regenerates in background
// => Subsequent requests: serves newly generated version
return (
<article>
<h1>{post.title}</h1>
<time>{new Date(post.publishedAt).toLocaleDateString()}</time>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
);
}
// => Alternative: page-level revalidation
export const revalidate = 300; // => 5 minutes
// => All fetches in this page inherit this revalidate value
// => Alternative: on-demand revalidation in CMS webhook
// app/api/revalidate/route.ts
import { revalidatePath } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
// => Webhook from CMS when post updated
const body = await request.json();
const slug = body.slug; // => slug is "zakat-guide"
// => Verify webhook secret (security)
const secret = request.headers.get('x-webhook-secret');
if (secret !== process.env.WEBHOOK_SECRET) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// => Revalidate specific post
revalidatePath(`/blog/${slug}`);
// => Regenerates page immediately, no waiting for revalidate timer
return NextResponse.json({ revalidated: true });
}Key Takeaway: ISR combines static generation speed with dynamic content updates. Use time-based revalidation for automatic updates or on-demand revalidation for instant updates.
Expected Output: Blog posts load instantly from static cache. After 5 minutes, next request serves stale version while regenerating in background.
Common Pitfalls: Setting revalidate too low (increases server load), or not securing on-demand revalidation webhooks (security risk).
Example 53: Static Export for CDN Hosting
Export fully static site (no Node.js server required). Perfect for deploying to CDN or static hosting.
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'export', // => Enable static export
// => Generates static HTML/CSS/JS files in 'out' directory
// => Optional: configure trailing slashes
trailingSlash: true,
// => /about → /about/index.html (better for some CDNs)
// => Optional: configure base path
// basePath: '/blog',
// => All routes prefixed with /blog (e.g., /blog/posts/1)
};
module.exports = nextConfig;
// app/page.tsx
// => Static page (no server-side features)
export default function HomePage() {
return (
<div>
<h1>Static Site</h1>
<p>This page is fully static, no server required.</p>
</div>
);
}
// app/posts/[id]/page.tsx
// => Dynamic route with generateStaticParams
export async function generateStaticParams() {
return [
{ id: '1' },
{ id: '2' },
{ id: '3' },
];
}
// => MUST set dynamicParams = false for static export
export const dynamicParams = false;
// => Without this, build fails (can't generate unknown routes)
export default function PostPage({
params,
}: {
params: { id: string };
}) {
return (
<div>
<h1>Post {params.id}</h1>
</div>
);
}
// Build command:
// npm run build
// => Generates 'out' directory with static files
// => Deploy 'out' to any CDN (Cloudflare, Vercel, Netlify, S3, etc.)
Key Takeaway: Use output: ’export’ for fully static sites. No Node.js server needed, deploy to any CDN. Requires generateStaticParams and dynamicParams = false for dynamic routes.
Expected Output: Build generates ‘out’ directory with HTML/CSS/JS files. Deploy to CDN for global distribution and instant loads.
Common Pitfalls: Using server-only features (Server Actions, cookies, headers) in static export (build fails), or forgetting dynamicParams = false (build error).
Group 2: Streaming & Suspense
Example 54: Streaming with Suspense Boundaries
Stream page sections independently to show content as it loads. Improves perceived performance and user experience.
// app/dashboard/page.tsx
import { Suspense } from 'react';
// => Fast component (renders immediately)
function QuickStats() {
return (
<div>
<h2>Quick Stats</h2>
<p>Last login: {new Date().toLocaleTimeString()}</p>
</div>
);
}
// => Slow component (async data fetch)
async function RecentDonations() {
// => Simulate slow database query
await new Promise(resolve => setTimeout(resolve, 3000)); // => 3 second delay
const donations = [
{ id: 1, amount: 100000, date: '2026-01-20' },
{ id: 2, amount: 250000, date: '2026-01-25' },
];
return (
<div>
<h2>Recent Donations</h2>
<ul>
{donations.map(d => (
<li key={d.id}>
IDR {d.amount.toLocaleString()} - {d.date}
</li>
))}
</ul>
</div>
);
}
// => Another slow component
async function AnalyticsChart() {
await new Promise(resolve => setTimeout(resolve, 2000)); // => 2 second delay
return (
<div>
<h2>Analytics</h2>
<p>Total donations this month: IDR 350,000</p>
</div>
);
}
export default function DashboardPage() {
return (
<div>
<h1>Dashboard</h1>
{/* => Fast content renders immediately */}
<QuickStats />
{/* => Suspense boundary for slow content */}
<Suspense fallback={<p>Loading donations...</p>}>
{/* => Shows fallback while RecentDonations loads */}
<RecentDonations />
{/* => Replaced with actual content when ready */}
</Suspense>
{/* => Separate Suspense boundary (independent loading) */}
<Suspense fallback={<p>Loading analytics...</p>}>
<AnalyticsChart />
{/* => Loads independently from donations */}
</Suspense>
</div>
);
}
// => HTML streamed in chunks:
// => 1. Initial HTML with QuickStats + fallbacks (instant)
// => 2. AnalyticsChart HTML streamed after 2 seconds
// => 3. RecentDonations HTML streamed after 3 seconds
Key Takeaway: Use Suspense boundaries to stream page sections independently. Fast content shows immediately, slow content streams as ready.
Expected Output: Dashboard shows heading and QuickStats instantly. “Loading analytics…” appears, replaced after 2s. “Loading donations…” replaced after 3s.
Common Pitfalls: Single Suspense wrapping all slow content (loses independent streaming), or not providing fallback (required prop).
Example 55: Nested Suspense for Progressive Loading
Nest Suspense boundaries to create progressive loading experiences. Outer boundary for page structure, inner boundaries for details.
// app/posts/[id]/page.tsx
import { Suspense } from 'react';
// => Post header (fast)
async function PostHeader({ id }: { id: string }) {
// => Quick database query (indexed)
await new Promise(resolve => setTimeout(resolve, 500)); // => 0.5s
return (
<header>
<h1>Post {id}</h1>
<p>By Author Name</p>
</header>
);
}
// => Post content (slower)
async function PostContent({ id }: { id: string }) {
await new Promise(resolve => setTimeout(resolve, 1500)); // => 1.5s
return (
<article>
<p>This is the post content. It takes longer to load because it's large.</p>
</article>
);
}
// => Comments (slowest)
async function PostComments({ id }: { id: string }) {
await new Promise(resolve => setTimeout(resolve, 3000)); // => 3s
const comments = [
{ id: 1, author: 'Ahmad', text: 'Great post!' },
{ id: 2, author: 'Fatima', text: 'Very helpful.' },
];
return (
<section>
<h2>Comments</h2>
<ul>
{comments.map(c => (
<li key={c.id}>
<strong>{c.author}</strong>: {c.text}
</li>
))}
</ul>
</section>
);
}
export default function PostPage({
params,
}: {
params: { id: string };
}) {
return (
<div>
{/* => Outer Suspense: page structure */}
<Suspense fallback={<div>Loading post...</div>}>
{/* => Header loads first (0.5s) */}
<PostHeader id={params.id} />
{/* => Inner Suspense: content section */}
<Suspense fallback={<p>Loading content...</p>}>
<PostContent id={params.id} />
{/* => Content streams after 1.5s */}
</Suspense>
{/* => Inner Suspense: comments section */}
<Suspense fallback={<p>Loading comments...</p>}>
<PostComments id={params.id} />
{/* => Comments stream after 3s */}
</Suspense>
</Suspense>
</div>
);
}
// => Progressive loading timeline:
// => 0ms: "Loading post..." shown
// => 500ms: Header + "Loading content..." + "Loading comments..."
// => 1500ms: Header + Content + "Loading comments..."
// => 3000ms: Header + Content + Comments (fully loaded)
Key Takeaway: Nest Suspense boundaries for progressive loading. Show page structure first, then fill in details as data arrives.
Expected Output: Post page shows loading state, then progressively reveals header (0.5s), content (1.5s), comments (3s).
Common Pitfalls: Not nesting Suspense (all-or-nothing loading), or too many boundaries (choppy UX with many loading states).
Example 56: Suspense with Skeleton UI
Use skeleton components as Suspense fallbacks for better perceived performance. Shows content structure while loading.
// app/components/Skeleton.tsx
// => Reusable skeleton components
export function SkeletonCard() {
return (
<div
style={{
background: '#e0e0e0',
borderRadius: '8px',
padding: '1rem',
animation: 'pulse 1.5s ease-in-out infinite',
}}
>
<div
style={{
height: '1.5rem',
background: '#d0d0d0',
borderRadius: '4px',
marginBottom: '0.5rem',
}}
/>
<div
style={{
height: '4rem',
background: '#d0d0d0',
borderRadius: '4px',
}}
/>
</div>
);
}
export function SkeletonList({ count = 3 }: { count?: number }) {
return (
<div>
{Array.from({ length: count }).map((_, i) => (
<div
key={i}
style={{
height: '3rem',
background: '#e0e0e0',
borderRadius: '4px',
marginBottom: '0.5rem',
animation: 'pulse 1.5s ease-in-out infinite',
}}
/>
))}
</div>
);
}
// app/posts/page.tsx
import { Suspense } from 'react';
import { SkeletonList } from '../components/Skeleton';
async function PostList() {
await new Promise(resolve => setTimeout(resolve, 2000));
const posts = [
{ id: 1, title: 'Zakat Guide' },
{ id: 2, title: 'Murabaha Basics' },
{ id: 3, title: 'Islamic Finance 101' },
];
return (
<ul>
{posts.map(post => (
<li
key={post.id}
style={{
padding: '1rem',
border: '1px solid #ddd',
borderRadius: '4px',
marginBottom: '0.5rem',
}}
>
<h3>{post.title}</h3>
<p>Published on Jan 29, 2026</p>
</li>
))}
</ul>
);
}
export default function PostsPage() {
return (
<div>
<h1>Blog Posts</h1>
{/* => Suspense with skeleton fallback */}
<Suspense fallback={<SkeletonList count={3} />}>
{/* => Shows 3 skeleton cards while loading */}
<PostList />
{/* => Replaced with actual posts after 2s */}
</Suspense>
</div>
);
}
// => Add CSS for pulse animation (global.css)
// @keyframes pulse {
// 0%, 100% { opacity: 1; }
// 50% { opacity: 0.5; }
// }
Key Takeaway: Use skeleton components as Suspense fallbacks to show content structure while loading. Improves perceived performance and reduces layout shift.
Expected Output: Posts page shows 3 pulsing skeleton cards for 2 seconds, then actual post list slides in. No layout shift.
Common Pitfalls: Skeleton doesn’t match actual content layout (causes layout shift), or skeleton too complex (defeats purpose of fast fallback).
Group 3: Advanced Caching Strategies
Example 57: Custom Cache with unstable_cache
Use unstable_cache to cache expensive operations with custom keys and revalidation rules. Perfect for database queries or computations.
// app/lib/cache.ts
import { unstable_cache } from 'next/cache';
// => Import Next.js cache function
// => Expensive calculation function
async function calculateZakatRates() {
console.log('Calculating zakat rates (expensive operation)...');
// => Simulate expensive computation
await new Promise(resolve => setTimeout(resolve, 2000)); // => 2 second delay
// => Fetch gold/silver prices from external API
const goldPrice = 950000; // => IDR per gram
const silverPrice = 12000; // => IDR per gram
return {
goldNisab: 85 * goldPrice, // => 80,750,000 IDR
silverNisab: 595 * silverPrice, // => 7,140,000 IDR
zakatRate: 0.025, // => 2.5%
};
}
// => Cached version of calculateZakatRates
export const getZakatRates = unstable_cache(
calculateZakatRates, // => Function to cache
['zakat-rates'], // => Cache key
{
revalidate: 3600, // => Revalidate every 1 hour
tags: ['zakat', 'rates'], // => Cache tags for revalidation
}
);
// => First call: runs calculateZakatRates, caches result
// => Subsequent calls within 1 hour: returns cached result (instant)
// app/zakat/page.tsx
import { getZakatRates } from '../lib/cache';
export default async function ZakatPage() {
// => Use cached function
const rates = await getZakatRates();
// => First request: 2 second delay
// => Subsequent requests: instant (cached)
return (
<div>
<h1>Zakat Calculator</h1>
<p>Gold Nisab: IDR {rates.goldNisab.toLocaleString()}</p>
<p>Silver Nisab: IDR {rates.silverNisab.toLocaleString()}</p>
<p>Zakat Rate: {rates.zakatRate * 100}%</p>
</div>
);
}
// app/actions.ts
'use server';
import { revalidateTag } from 'next/cache';
export async function updateGoldPrice() {
// => Update gold price in database
// await db.prices.update({ ... });
// => Invalidate zakat rates cache
revalidateTag('rates');
// => Next getZakatRates() call recalculates
}Key Takeaway: Use unstable_cache for custom caching of expensive operations. Set revalidation time and tags for granular cache control.
Expected Output: First page load calculates rates (2s delay). Subsequent loads instant (cached). revalidateTag forces recalculation.
Common Pitfalls: Using unstable_cache on frequently changing data (stale cache), or not setting appropriate revalidate time (too aggressive or too stale).
Example 58: Request Memoization with React cache()
Use React cache() to deduplicate identical function calls within a single request. Different from Next.js caching (doesn’t persist across requests).
// app/lib/data.ts
import { cache } from 'react';
// => Import React cache function
// => Expensive database query
async function getUserFromDB(userId: string) {
console.log(`Database query for user ${userId}`);
// => This log helps verify deduplication
// => Simulate database query
await new Promise(resolve => setTimeout(resolve, 100));
return {
id: userId,
name: 'Ahmad',
email: 'ahmad@example.com',
};
}
// => Memoized version (deduplicates within request)
export const getUser = cache(getUserFromDB);
// => Multiple calls with same userId in single request: only one database query
// => Different requests: cache doesn't persist (new query)
// app/components/UserProfile.tsx
import { getUser } from '../lib/data';
export async function UserProfile({ userId }: { userId: string }) {
const user = await getUser(userId); // => Call 1: database query
// => user is { id: "1", name: "Ahmad", ... }
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
}
// app/components/UserStats.tsx
import { getUser } from '../lib/data';
export async function UserStats({ userId }: { userId: string }) {
const user = await getUser(userId); // => Call 2: uses cached result (same request)
// => No database query, instant return
return (
<div>
<p>User ID: {user.id}</p>
</div>
);
}
// app/users/[id]/page.tsx
import { UserProfile } from '@/app/components/UserProfile';
import { UserStats } from '@/app/components/UserStats';
export default function UserPage({
params,
}: {
params: { id: string };
}) {
return (
<div>
<h1>User Details</h1>
{/* => Both components call getUser(params.id) */}
<UserProfile userId={params.id} />
{/* => Triggers database query */}
<UserStats userId={params.id} />
{/* => Uses memoized result, no second query */}
</div>
);
}
// => Single request: only ONE database query despite two getUser() calls
// => Next request: cache cleared, new database query
Key Takeaway: Use React cache() to deduplicate function calls within a single request. Different from Next.js caching (doesn’t persist across requests).
Expected Output: Server logs “Database query for user 1” only once per page request, despite multiple components calling getUser().
Common Pitfalls: Confusing React cache() with Next.js caching (different scopes), or expecting cache to persist across requests (it doesn’t).
Example 59: Force Dynamic Rendering
Use dynamic rendering modes to opt out of static generation for specific pages. Perfect for user-specific or time-sensitive content.
// app/profile/page.tsx
// => Force dynamic rendering (SSR on every request)
export const dynamic = 'force-dynamic';
// => Opts out of static generation
// => Page rendered on every request (Server-Side Rendering)
import { cookies } from 'next/headers';
export default async function ProfilePage() {
// => Read cookies (requires dynamic rendering)
const cookieStore = cookies();
const authToken = cookieStore.get('auth_token');
if (!authToken) {
return <p>Please log in.</p>;
}
// => Fetch user-specific data
const user = { name: 'Ahmad', lastLogin: new Date() };
return (
<div>
<h1>Your Profile</h1>
<p>Name: {user.name}</p>
<p>Last login: {user.lastLogin.toLocaleString()}</p>
{/* => Always shows current time (dynamic) */}
</div>
);
}
// Alternative: force-static for edge cases
// export const dynamic = 'force-static';
// => Forces static generation even if cookies/headers used (throws error if incompatible)
// Alternative: auto (default)
// export const dynamic = 'auto';
// => Next.js decides based on API usage (cookies/headers = dynamic, else static)
Key Takeaway: Use dynamic = ‘force-dynamic’ to opt out of static generation. Required for user-specific content, current timestamps, or cookie-dependent pages.
Expected Output: Profile page renders on every request with fresh data. Shows current last login time, not build-time value.
Common Pitfalls: Not setting force-dynamic when using cookies/headers (Next.js auto-detects but explicit is clearer), or using force-dynamic unnecessarily (slower than static).
Group 4: Performance Optimization
Example 60: Image Optimization with Blur Placeholder
Use blur placeholder for better perceived performance during image loading. Creates inline base64 blur preview.
// app/gallery/page.tsx
import Image from 'next/image';
// => Import images statically for automatic blur generation
import mosqueImage from './mosque.jpg';
// => Next.js automatically generates blur data URL at build time
export default function GalleryPage() {
return (
<div>
<h1>Islamic Architecture Gallery</h1>
{/* => Static import with automatic blur */}
<Image
src={mosqueImage}
// => Static import, Next.js knows dimensions automatically
alt="Beautiful mosque with Islamic architecture"
placeholder="blur"
// => Shows blur placeholder while loading
// => blur data generated automatically from mosqueImage
/>
{/* => Dynamic image with custom blur data URL */}
<Image
src="https://example.com/remote-image.jpg"
alt="Remote Islamic art"
width={800}
height={600}
placeholder="blur"
blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD..."
// => Custom blur data URL (generate with tools like plaiceholder)
// => Shows this blurred version while full image loads
/>
{/* => Multiple images with priority */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '1rem' }}>
{[1, 2, 3].map(id => (
<div key={id} style={{ position: 'relative', aspectRatio: '16/9' }}>
<Image
src={`/gallery/image-${id}.jpg`}
alt={`Gallery image ${id}`}
fill
sizes="(max-width: 768px) 100vw, 33vw"
// => Tells Next.js image sizes for responsive optimization
// => Mobile: 100% viewport width
// => Desktop: 33% viewport width (3 columns)
/>
</div>
))}
</div>
</div>
);
}Key Takeaway: Use Image component with placeholder=“blur” for smooth loading transitions. Static imports get automatic blur, remote images need blurDataURL.
Expected Output: Images show blurred preview instantly while loading. Smooth transition to sharp image when loaded. No layout shift.
Common Pitfalls: Using blur placeholder on remote images without blurDataURL (error), or not setting sizes prop on responsive images (suboptimal optimization).
Example 61: Font Optimization with next/font
Use next/font for automatic font optimization. Self-hosts fonts, eliminates external requests, enables font swapping.
// app/layout.tsx
import { Inter, Amiri } from 'next/font/google';
// => Import Google Fonts (self-hosted automatically)
// => Configure Inter font (Latin)
const inter = Inter({
subsets: ['latin'], // => Only load Latin characters
display: 'swap', // => Font display strategy (swap for better performance)
variable: '--font-inter', // => CSS variable name
});
// => Configure Amiri font (Arabic)
const amiri = Amiri({
weight: ['400', '700'], // => Specific weights only
subsets: ['arabic'], // => Only load Arabic characters
display: 'swap',
variable: '--font-amiri',
});
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" className={`${inter.variable} ${amiri.variable}`}>
{/* => Apply font CSS variables to html element */}
<body className={inter.className}>
{/* => Default font is Inter */}
{children}
</body>
</html>
);
}
// app/components/ArabicText.tsx
export function ArabicText({ children }: { children: React.ReactNode }) {
return (
<p style={{ fontFamily: 'var(--font-amiri)' }}>
{/* => Use Amiri font for Arabic text */}
{children}
</p>
);
}
// Alternative: Local fonts
// app/layout.tsx
import localFont from 'next/font/local';
const customFont = localFont({
src: './fonts/CustomFont.woff2', // => Path to local font file
display: 'swap',
variable: '--font-custom',
});Key Takeaway: Use next/font for automatic font optimization. Self-hosts fonts, eliminates external requests, enables preloading, and prevents layout shift.
Expected Output: Fonts load instantly (self-hosted, preloaded). No FOUT (Flash of Unstyled Text). Better performance than Google Fonts CDN.
Common Pitfalls: Loading too many font weights/subsets (larger bundle), or not setting display strategy (default may cause FOIT).
Example 62: Script Optimization with next/script
Use next/script for optimal third-party script loading. Controls when and how scripts load without blocking rendering.
// app/layout.tsx
import Script from 'next/script';
// => Import Script component
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
{children}
{/* => Critical analytics script (load after interactive) */}
<Script
src="https://www.googletagmanager.com/gtag/js?id=GA_MEASUREMENT_ID"
strategy="afterInteractive"
// => afterInteractive: load after page interactive (default)
// => Doesn't block user interaction
/>
{/* => Initialize analytics */}
<Script id="google-analytics" strategy="afterInteractive">
{`
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'GA_MEASUREMENT_ID');
`}
</Script>
{/* => Non-critical widget (lazy load) */}
<Script
src="https://widget.example.com/widget.js"
strategy="lazyOnload"
// => lazyOnload: load after page fully loaded
// => Lowest priority, doesn't affect performance
/>
{/* => Script with callback */}
<Script
src="https://maps.googleapis.com/maps/api/js?key=API_KEY"
strategy="afterInteractive"
onLoad={() => {
// => Callback when script loaded
console.log('Google Maps script loaded');
}}
onError={(e) => {
// => Callback on load error
console.error('Failed to load Google Maps', e);
}}
/>
</body>
</html>
);
}
// app/components/CustomWidget.tsx
'use client';
import Script from 'next/script';
export function CustomWidget() {
return (
<>
{/* => Component-level script (only loads when component renders) */}
<Script
src="https://widget.example.com/component-widget.js"
strategy="lazyOnload"
/>
<div id="widget-container" />
</>
);
}Key Takeaway: Use next/script for optimal third-party script loading. strategy prop controls loading priority: afterInteractive (default), lazyOnload (lowest priority), beforeInteractive (rare, high priority).
Expected Output: Critical scripts load after page interactive. Non-critical scripts load after page fully loaded. No blocking during initial render.
Common Pitfalls: Using strategy=“beforeInteractive” unnecessarily (blocks rendering), or loading scripts directly in HTML head (unoptimized).
Group 5: Advanced Metadata & SEO
Example 63: Dynamic OpenGraph Images
Generate dynamic OpenGraph images for social media sharing. Perfect for blog posts, product pages, dynamic content.
// app/api/og/route.tsx
// => API route that generates OG images
import { ImageResponse } from 'next/og';
// => Import ImageResponse for OG image generation
export const runtime = 'edge'; // => Use Edge Runtime for fast responses
export async function GET(request: Request) {
// => Extract query parameters
const { searchParams } = new URL(request.url);
const title = searchParams.get('title') || 'Default Title';
// => title is "Zakat Guide" from /api/og?title=Zakat%20Guide
// => Return ImageResponse (generates image)
return new ImageResponse(
(
// => JSX-like syntax for image layout
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
height: '100%',
background: 'linear-gradient(135deg, #0173B2 0%, #029E73 100%)',
color: 'white',
fontFamily: 'sans-serif',
}}
>
<h1 style={{ fontSize: '64px', margin: 0 }}>{title}</h1>
<p style={{ fontSize: '32px', margin: '20px 0 0' }}>Islamic Finance Platform</p>
</div>
),
{
width: 1200, // => OG image width (recommended: 1200px)
height: 630, // => OG image height (recommended: 630px)
}
);
// => Returns PNG image with dynamic content
}
// app/blog/[slug]/page.tsx
import { Metadata } from 'next';
export async function generateMetadata({
params,
}: {
params: { slug: string };
}): Promise<Metadata> {
// => Fetch post data
const post = { title: 'Zakat Guide', description: 'Complete guide to Zakat calculation' };
// => Generate OG image URL
const ogImageUrl = new URL('/api/og', 'https://example.com');
ogImageUrl.searchParams.set('title', post.title);
// => ogImageUrl is "https://example.com/api/og?title=Zakat%20Guide"
return {
title: post.title,
description: post.description,
openGraph: {
title: post.title,
description: post.description,
images: [
{
url: ogImageUrl.toString(),
// => Dynamic OG image for this post
width: 1200,
height: 630,
},
],
},
twitter: {
card: 'summary_large_image',
title: post.title,
description: post.description,
images: [ogImageUrl.toString()],
},
};
}
export default function BlogPostPage() {
return (
<article>
<h1>Blog Post</h1>
</article>
);
}Key Takeaway: Use ImageResponse API to generate dynamic OpenGraph images. Perfect for social media sharing with post-specific visuals.
Expected Output: Sharing blog post on social media shows custom image with post title. Each post gets unique OG image.
Common Pitfalls: Not using Edge Runtime (slower response), or wrong image dimensions (social platforms crop incorrectly).
Example 64: JSON-LD Structured Data for SEO
Add JSON-LD structured data for rich search results. Helps search engines understand content type and display rich snippets.
// app/products/[id]/page.tsx
import { Metadata } from 'next';
// => Product data
const product = {
id: '1',
name: 'Murabaha Financing',
description: 'Sharia-compliant cost-plus financing for asset purchases',
price: 1000000,
currency: 'IDR',
availability: 'InStock',
rating: 4.8,
reviewCount: 127,
};
export const metadata: Metadata = {
title: `${product.name} | Islamic Finance`,
description: product.description,
};
export default function ProductPage() {
// => JSON-LD structured data
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'Product',
name: product.name,
description: product.description,
offers: {
'@type': 'Offer',
price: product.price,
priceCurrency: product.currency,
availability: `https://schema.org/${product.availability}`,
},
aggregateRating: {
'@type': 'AggregateRating',
ratingValue: product.rating,
reviewCount: product.reviewCount,
},
};
return (
<div>
{/* => Inject JSON-LD in script tag */}
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
{/* => Search engines parse this for rich results */}
<h1>{product.name}</h1>
<p>{product.description}</p>
<p>Price: {product.currency} {product.price.toLocaleString()}</p>
<p>Rating: {product.rating} ({product.reviewCount} reviews)</p>
</div>
);
}
// Alternative: Article structured data
// app/blog/[slug]/page.tsx
export default function BlogPostPage() {
const articleJsonLd = {
'@context': 'https://schema.org',
'@type': 'Article',
headline: 'Zakat Calculation Guide',
description: 'Complete guide to calculating Zakat on wealth',
author: {
'@type': 'Person',
name: 'Ahmad Ibrahim',
},
datePublished: '2026-01-15',
dateModified: '2026-01-20',
publisher: {
'@type': 'Organization',
name: 'Islamic Finance Platform',
logo: {
'@type': 'ImageObject',
url: 'https://example.com/logo.png',
},
},
};
return (
<article>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(articleJsonLd) }}
/>
<h1>Zakat Calculation Guide</h1>
</article>
);
}Key Takeaway: Add JSON-LD structured data for rich search results. Helps Google show star ratings, prices, article info in search results.
Expected Output: Google search shows product with star rating, price, availability. Articles show author, publish date, read time.
Common Pitfalls: Invalid schema (use Google Structured Data Testing Tool), or missing required fields (schema won’t validate).
Group 6: Deployment & Production Patterns
Example 65: Environment Variables with Type Safety
Use environment variables with TypeScript validation for type-safe configuration. Prevents runtime errors from missing/invalid env vars.
// env.ts
// => Environment variable validation schema
import { z } from "zod";
// => Define schema for environment variables
const envSchema = z.object({
// => Required variables
DATABASE_URL: z.string().url(),
// => Must be valid URL
NEXTAUTH_SECRET: z.string().min(32),
// => Minimum 32 characters (security)
NEXTAUTH_URL: z.string().url(),
// => Optional variables with defaults
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
// => Public variables (NEXT_PUBLIC_ prefix)
NEXT_PUBLIC_API_URL: z.string().url(),
});
// => Parse and validate environment variables at build time
const env = envSchema.parse({
DATABASE_URL: process.env.DATABASE_URL,
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
NEXTAUTH_URL: process.env.NEXTAUTH_URL,
NODE_ENV: process.env.NODE_ENV,
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
});
// => If validation fails, build fails with clear error
// => Export typed environment variables
export default env;
// app/lib/db.ts
import env from "../env";
// => Use typed environment variables
export const dbConnection = env.DATABASE_URL;
// => env.DATABASE_URL is string (type-safe)
// app/config.ts
import env from "./env";
export const config = {
database: {
url: env.DATABASE_URL, // => Type: string (validated URL)
},
auth: {
secret: env.NEXTAUTH_SECRET, // => Type: string (min 32 chars)
url: env.NEXTAUTH_URL,
},
api: {
url: env.NEXT_PUBLIC_API_URL, // => Public, accessible in browser
},
isProduction: env.NODE_ENV === "production",
};
// Usage in components/actions
// app/actions.ts
("use server");
import { config } from "./config";
export async function connectToDatabase() {
// => Type-safe config access
const dbUrl = config.database.url; // => Type: string
// => No need for optional chaining or type assertions
console.log(`Connecting to database: ${dbUrl}`);
}Key Takeaway: Validate environment variables at build time with Zod. Provides type safety, prevents runtime errors, documents required configuration.
Expected Output: Build fails immediately if required env vars missing or invalid. Runtime code gets type-safe environment variables.
Common Pitfalls: Not validating env vars (runtime errors in production), or exposing secrets (only NEXTPUBLIC variables safe in browser).
Example 66: Monitoring with OpenTelemetry
Add OpenTelemetry instrumentation for observability. Tracks requests, database queries, external API calls.
// instrumentation.ts
// => Special file: Next.js loads this before app initialization
export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
// => Only instrument Node.js runtime (not Edge)
const { registerOTel } = await import("@vercel/otel");
registerOTel({
serviceName: "islamic-finance-platform",
// => Service name in tracing system
});
}
}
// app/lib/monitoring.ts
import { trace } from "@opentelemetry/api";
// => Create tracer for custom spans
const tracer = trace.getTracer("app");
export async function tracedDatabaseQuery<T>(name: string, query: () => Promise<T>): Promise<T> {
// => Create span for database query
return tracer.startActiveSpan(`db.query.${name}`, async (span) => {
try {
const result = await query();
// => Query succeeded
span.setStatus({ code: 1 }); // => 1 = OK
span.end();
return result;
} catch (error) {
// => Query failed
span.setStatus({
code: 2, // => 2 = ERROR
message: error instanceof Error ? error.message : "Unknown error",
});
span.recordException(error as Error);
span.end();
throw error;
}
});
}
// app/lib/data.ts
import { prisma } from "./prisma";
import { tracedDatabaseQuery } from "./monitoring";
export async function getPosts() {
// => Traced database query
return tracedDatabaseQuery("get-posts", async () => {
return prisma.post.findMany();
});
// => Appears in tracing system with timing and status
}
// app/api/health/route.ts
// => Health check endpoint for monitoring
export async function GET() {
try {
// => Check database connection
await prisma.$queryRaw`SELECT 1`;
return Response.json({ status: "healthy" });
} catch (error) {
return Response.json({ status: "unhealthy" }, { status: 503 });
}
}Key Takeaway: Use OpenTelemetry for observability in production. Trace requests, database queries, external calls. Critical for debugging performance issues.
Expected Output: Tracing system (Vercel, Datadog, Honeycomb) shows request traces with timing breakdowns. Easy to identify slow queries.
Common Pitfalls: Over-instrumenting (too many spans slow down app), or not instrumenting critical paths (can’t debug performance issues).
Example 67: Rate Limiting with Upstash
Implement rate limiting to protect API routes from abuse. Uses Upstash Redis for distributed rate limiting.
// app/lib/rate-limit.ts
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
// => Create Redis client
const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});
// => Create rate limiter (sliding window)
const ratelimit = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(10, "10 s"),
// => 10 requests per 10 seconds
analytics: true, // => Track usage analytics
});
export async function checkRateLimit(identifier: string) {
// => Check rate limit for identifier (user ID, IP address, etc.)
const { success, limit, reset, remaining } = await ratelimit.limit(identifier);
// => success: boolean (request allowed?)
// => limit: number (max requests)
// => reset: number (timestamp when limit resets)
// => remaining: number (requests remaining)
return {
allowed: success,
limit,
remaining,
reset: new Date(reset),
};
}
// app/api/donations/route.ts
import { NextRequest, NextResponse } from "next/server";
import { checkRateLimit } from "@/app/lib/rate-limit";
export async function POST(request: NextRequest) {
// => Get client identifier (IP address)
const ip = request.ip ?? request.headers.get("x-forwarded-for") ?? "anonymous";
// => Check rate limit
const rateLimit = await checkRateLimit(ip);
if (!rateLimit.allowed) {
// => Rate limit exceeded
return NextResponse.json(
{
error: "Too many requests",
limit: rateLimit.limit,
reset: rateLimit.reset,
},
{
status: 429, // => HTTP 429 Too Many Requests
headers: {
"X-RateLimit-Limit": rateLimit.limit.toString(),
"X-RateLimit-Remaining": rateLimit.remaining.toString(),
"X-RateLimit-Reset": rateLimit.reset.toISOString(),
},
},
);
}
// => Rate limit passed, process request
const body = await request.json();
// => Handle donation
console.log("Processing donation:", body);
return NextResponse.json(
{ success: true },
{
headers: {
// => Add rate limit headers to successful responses too
"X-RateLimit-Limit": rateLimit.limit.toString(),
"X-RateLimit-Remaining": rateLimit.remaining.toString(),
"X-RateLimit-Reset": rateLimit.reset.toISOString(),
},
},
);
}Key Takeaway: Implement rate limiting on API routes to prevent abuse. Use distributed rate limiting (Upstash Redis) for multi-instance deployments.
Expected Output: API allows 10 requests per 10 seconds per IP. Exceeding limit returns 429 status with reset time. Headers show limit/remaining.
Common Pitfalls: Using in-memory rate limiting (doesn’t work across multiple instances), or not returning rate limit headers (clients can’t track usage).
Group 7: Advanced Patterns
Example 68: Server-Only Code Protection
Use ‘server-only’ package to ensure code never bundles in client. Prevents accidental exposure of secrets or server logic.
// app/lib/secrets.ts
// => Import server-only at top of file
import 'server-only';
// => If this file imported in Client Component, build fails with error
// => Server-only function (accesses environment secrets)
export function getSecretKey() {
// => This function should NEVER run on client
return process.env.SECRET_API_KEY;
}
export async function queryDatabase(sql: string) {
// => Database credentials only on server
const db = process.env.DATABASE_URL;
// => If accidentally imported in client code, build fails
console.log(`Querying: ${sql}`);
}
// app/actions.ts
'use server';
// => Safe to import in Server Actions
import { getSecretKey, queryDatabase } from './lib/secrets';
export async function fetchUserData() {
const apiKey = getSecretKey(); // => Server-side only
await queryDatabase('SELECT * FROM users');
return { success: true };
}
// app/components/ClientComponent.tsx
'use client';
// => This import would cause BUILD ERROR
// import { getSecretKey } from '../lib/secrets';
// => Error: "You're importing a component that needs server-only. Use it in a Server Component instead."
export default function ClientComponent() {
return <div>Client Component</div>;
}
// Alternative: client-only protection
// app/lib/browser-only.ts
import 'client-only';
// => Ensures code only runs on client (never on server)
export function getLocalStorage(key: string) {
// => window only exists on client
return window.localStorage.getItem(key);
}Key Takeaway: Use ‘server-only’ package to protect server code from accidental client bundling. Build fails if server code imported in Client Component.
Expected Output: Build fails if server-only code imported in client. Prevents accidental secret exposure.
Common Pitfalls: Not using server-only for sensitive code (secrets might leak to client bundle), or forgetting to install package (no protection).
Example 69: Partial Prerendering (PPR) Pattern
Combine static shell with dynamic content for best of both worlds. Static parts load instantly, dynamic parts stream in.
// app/dashboard/page.tsx
import { Suspense } from 'react';
// => Static component (pre-rendered at build time)
function DashboardShell() {
return (
<div>
<header>
<h1>Dashboard</h1>
{/* => Static heading, pre-rendered */}
</header>
<nav>
{/* => Static navigation, pre-rendered */}
<a href="/dashboard">Overview</a>
<a href="/dashboard/stats">Stats</a>
<a href="/dashboard/settings">Settings</a>
</nav>
</div>
);
}
// => Dynamic component (renders at request time)
async function UserGreeting() {
// => Fetch user-specific data
const user = { name: 'Ahmad', lastLogin: new Date() };
return (
<div>
<p>Welcome back, {user.name}!</p>
<p>Last login: {user.lastLogin.toLocaleString()}</p>
</div>
);
}
// => Another dynamic component
async function RecentActivity() {
// => Fetch recent activity (user-specific)
await new Promise(resolve => setTimeout(resolve, 1000));
const activities = [
{ id: 1, text: 'Donated IDR 100,000' },
{ id: 2, text: 'Updated profile' },
];
return (
<ul>
{activities.map(activity => (
<li key={activity.id}>{activity.text}</li>
))}
</ul>
);
}
export default function DashboardPage() {
return (
<div>
{/* => Static shell (pre-rendered) */}
<DashboardShell />
{/* => Dynamic sections (streamed) */}
<Suspense fallback={<p>Loading greeting...</p>}>
<UserGreeting />
</Suspense>
<Suspense fallback={<p>Loading activity...</p>}>
<RecentActivity />
</Suspense>
</div>
);
}
// => PPR behavior:
// => 1. Static shell served instantly from CDN (header, nav)
// => 2. Dynamic sections stream in as ready (greeting, activity)
// => Best of both: instant load + personalized content
Key Takeaway: Partial Prerendering combines static shell with dynamic content. Static parts load instantly, dynamic parts stream in, best user experience.
Expected Output: Dashboard header and navigation appear instantly (static). Greeting and activity stream in shortly after (dynamic, personalized).
Common Pitfalls: Not wrapping dynamic parts in Suspense (entire page becomes dynamic), or putting static content inside Suspense (defeats purpose).
Example 70: Middleware Chaining Pattern
Chain multiple middleware functions for composable request processing. Cleaner than single monolithic middleware.
// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
// => Middleware function type
type MiddlewareFunction = (request: NextRequest) => NextResponse | Promise<NextResponse> | void | Promise<void>;
// => Middleware composer
function chain(...middlewares: MiddlewareFunction[]) {
return async (request: NextRequest) => {
// => Run middlewares in sequence
for (const middleware of middlewares) {
const response = await middleware(request);
// => If middleware returns response, stop chain
if (response) return response;
}
// => No middleware returned response, continue
return NextResponse.next();
};
}
// => Logging middleware
function loggerMiddleware(request: NextRequest) {
console.log(`[${new Date().toISOString()}] ${request.method} ${request.nextUrl.pathname}`);
// => No return = continue to next middleware
}
// => Auth middleware
async function authMiddleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// => Check if route is protected
if (pathname.startsWith("/dashboard")) {
const authToken = request.cookies.get("auth_token");
if (!authToken) {
// => Not authenticated, redirect
return NextResponse.redirect(new URL("/login", request.url));
// => Returns response, stops chain
}
}
// => Authenticated or public route, continue
}
// => Rate limit middleware
async function rateLimitMiddleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// => Only rate limit API routes
if (pathname.startsWith("/api/")) {
const ip = request.ip ?? "anonymous";
// => Check rate limit (simplified)
const isAllowed = true; // => Actual rate limit check here
if (!isAllowed) {
return NextResponse.json({ error: "Too many requests" }, { status: 429 });
}
}
}
// => Security headers middleware
function securityHeadersMiddleware(request: NextRequest) {
const response = NextResponse.next();
// => Add security headers
response.headers.set("X-Frame-Options", "DENY");
response.headers.set("X-Content-Type-Options", "nosniff");
response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
return response;
}
// => Export chained middleware
export default chain(loggerMiddleware, authMiddleware, rateLimitMiddleware, securityHeadersMiddleware);
// => Middlewares run in order: log → auth → rate limit → security headers
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};Key Takeaway: Chain middleware functions for composable request processing. Each middleware focuses on single responsibility, easier to maintain.
Expected Output: Every request runs through logger → auth → rate limit → security headers. Each middleware can short-circuit by returning response.
Common Pitfalls: Order matters (auth before rate limit), or forgetting to return NextResponse (middleware won’t work).
Group 8: Multi-Step Forms & Background Jobs
Example 71: Multi-Step Form with Server Actions
Implement multi-step form wizard using Server Actions and session storage. Maintains state across steps with validation.
// app/lib/session.ts
'use server';
import { cookies } from 'next/headers';
// => Store multi-step form data in encrypted cookie
export async function saveFormStep(stepData: Record<string, any>) {
// => Get existing form data
const cookieStore = cookies();
const existingData = cookieStore.get('form_data');
// => Merge with new step data
const formData = existingData
? { ...JSON.parse(existingData.value), ...stepData }
: stepData;
// => formData is { step1Data, step2Data, ... }
// => Save to cookie (simplified - use encryption in production)
cookieStore.set('form_data', JSON.stringify(formData), {
httpOnly: true,
maxAge: 60 * 30, // => 30 minutes
});
return formData;
}
export async function getFormData() {
const cookieStore = cookies();
const formData = cookieStore.get('form_data');
return formData ? JSON.parse(formData.value) : {};
}
export async function clearFormData() {
cookies().delete('form_data');
}
// app/actions.ts
'use server';
import { z } from 'zod';
import { saveFormStep, getFormData, clearFormData } from './lib/session';
import { redirect } from 'next/navigation';
// => Step 1 schema: Personal info
const step1Schema = z.object({
name: z.string().min(2, 'Name required'),
email: z.string().email('Invalid email'),
});
// => Step 2 schema: Donation details
const step2Schema = z.object({
amount: z.number().min(10000, 'Minimum IDR 10,000'),
category: z.enum(['zakat', 'sadaqah', 'infaq']),
});
// => Step 3 schema: Payment method
const step3Schema = z.object({
paymentMethod: z.enum(['bank_transfer', 'credit_card', 'e_wallet']),
agreeTerms: z.literal(true, { errorMap: () => ({ message: 'Must agree to terms' }) }),
});
export async function submitStep1(formData: FormData) {
// => Validate step 1
const result = step1Schema.safeParse({
name: formData.get('name'),
email: formData.get('email'),
});
if (!result.success) {
return { errors: result.error.flatten().fieldErrors };
}
// => Save step 1 data
await saveFormStep({ step1: result.data });
// => Redirect to step 2
redirect('/donate/step-2');
}
export async function submitStep2(formData: FormData) {
const result = step2Schema.safeParse({
amount: parseFloat(formData.get('amount') as string),
category: formData.get('category'),
});
if (!result.success) {
return { errors: result.error.flatten().fieldErrors };
}
await saveFormStep({ step2: result.data });
redirect('/donate/step-3');
}
export async function submitStep3(formData: FormData) {
const result = step3Schema.safeParse({
paymentMethod: formData.get('paymentMethod'),
agreeTerms: formData.get('agreeTerms') === 'true',
});
if (!result.success) {
return { errors: result.error.flatten().fieldErrors };
}
// => Get all form data
const allData = await getFormData();
// => allData is { step1: {...}, step2: {...}, step3: {...} }
// => Process complete form
console.log('Complete donation:', {
...allData.step1,
...allData.step2,
...result.data,
});
// => Clear form data
await clearFormData();
// => Redirect to success page
redirect('/donate/success');
}
// app/donate/step-1/page.tsx
import { submitStep1 } from '@/app/actions';
export default function Step1Page() {
return (
<div>
<h1>Step 1: Personal Information</h1>
<form action={submitStep1}>
<input type="text" name="name" placeholder="Name" required />
<input type="email" name="email" placeholder="Email" required />
<button type="submit">Next Step</button>
</form>
</div>
);
}
// app/donate/step-2/page.tsx
import { submitStep2, getFormData } from '@/app/actions';
import { redirect } from 'next/navigation';
export default async function Step2Page() {
// => Check if step 1 completed
const formData = await getFormData();
if (!formData.step1) {
redirect('/donate/step-1'); // => Redirect to step 1
}
return (
<div>
<h1>Step 2: Donation Details</h1>
<p>Donor: {formData.step1.name}</p>
<form action={submitStep2}>
<input type="number" name="amount" placeholder="Amount" required />
<select name="category" required>
<option value="zakat">Zakat</option>
<option value="sadaqah">Sadaqah</option>
<option value="infaq">Infaq</option>
</select>
<button type="submit">Next Step</button>
</form>
</div>
);
}Key Takeaway: Implement multi-step forms with Server Actions and session storage (cookies). Each step validates independently, data persists across steps.
Expected Output: Form guides user through 3 steps. Data saved after each step. Can’t skip steps. Final submission combines all step data.
Common Pitfalls: Not validating step order (users skip steps), or storing sensitive data in cookies without encryption (security risk).
Example 72: Background Jobs with Server Actions
Trigger background jobs from Server Actions using queue systems. Returns immediately while job processes asynchronously.
// app/lib/queue.ts
// => Simulated job queue (use real queue like BullMQ, Inngest in production)
type Job = {
id: string;
type: string;
data: any;
status: 'pending' | 'processing' | 'completed' | 'failed';
createdAt: Date;
completedAt?: Date;
};
// => In-memory job storage (use Redis/database in production)
const jobs = new Map<string, Job>();
export function enqueueJob(type: string, data: any): string {
// => Create job
const jobId = crypto.randomUUID();
const job: Job = {
id: jobId,
type,
data,
status: 'pending',
createdAt: new Date(),
};
jobs.set(jobId, job);
// => Process job asynchronously (don't await)
processJob(jobId);
return jobId;
// => Returns immediately, job processes in background
}
async function processJob(jobId: string) {
const job = jobs.get(jobId);
if (!job) return;
// => Update status
job.status = 'processing';
try {
// => Simulate long-running task
await new Promise(resolve => setTimeout(resolve, 10000)); // => 10 seconds
// => Job-specific processing
if (job.type === 'generate_report') {
console.log(`Generating report for: ${job.data.userId}`);
// => Generate PDF, send email, etc.
}
// => Mark complete
job.status = 'completed';
job.completedAt = new Date();
} catch (error) {
job.status = 'failed';
console.error('Job failed:', error);
}
}
export function getJobStatus(jobId: string): Job | undefined {
return jobs.get(jobId);
}
// app/actions.ts
'use server';
import { enqueueJob } from './lib/queue';
export async function generateReport(userId: string) {
// => Enqueue background job
const jobId = enqueueJob('generate_report', { userId });
// => Returns immediately, job processes in background
return {
success: true,
jobId,
message: 'Report generation started. You will receive an email when ready.',
};
// => User doesn't wait for 10-second job
}
export async function checkJobStatus(jobId: string) {
// => Server Action to check job status
const job = getJobStatus(jobId);
if (!job) {
return { error: 'Job not found' };
}
return {
status: job.status,
createdAt: job.createdAt,
completedAt: job.completedAt,
};
}
// app/reports/page.tsx
'use client';
import { useState } from 'react';
import { generateReport, checkJobStatus } from '../actions';
export default function ReportsPage() {
const [jobId, setJobId] = useState<string | null>(null);
const [status, setStatus] = useState<string>('');
async function handleGenerate() {
// => Trigger background job
const result = await generateReport('user123');
setJobId(result.jobId);
setStatus('pending');
// => Poll for status (simplified - use webhooks/WebSockets in production)
const interval = setInterval(async () => {
const statusResult = await checkJobStatus(result.jobId);
if (statusResult.status === 'completed') {
setStatus('completed');
clearInterval(interval);
} else if (statusResult.status === 'failed') {
setStatus('failed');
clearInterval(interval);
} else {
setStatus(statusResult.status);
}
}, 2000);
// => Check every 2 seconds
}
return (
<div>
<h1>Generate Report</h1>
<button onClick={handleGenerate} disabled={!!jobId}>
Generate Report
</button>
{jobId && (
<div>
<p>Job ID: {jobId}</p>
<p>Status: {status}</p>
{status === 'completed' && <p>✓ Report ready! Check your email.</p>}
{status === 'failed' && <p>✗ Report generation failed.</p>}
</div>
)}
</div>
);
}Key Takeaway: Use background jobs for long-running tasks. Server Action enqueues job and returns immediately. Poll or use webhooks for status updates.
Expected Output: Clicking “Generate Report” returns immediately. Job processes in background (10 seconds). Status updates every 2 seconds via polling.
Common Pitfalls: Running expensive operations synchronously in Server Actions (timeout), or not providing status updates (users don’t know progress).
Group 9: Advanced Authentication & Authorization
Example 73: Role-Based Access Control (RBAC)
Implement role-based access control with middleware and Server Components. Restricts access based on user roles.
// app/lib/auth.ts
import { cookies } from 'next/headers';
export type Role = 'admin' | 'moderator' | 'user';
export type User = {
id: string;
name: string;
email: string;
role: Role;
};
export async function getCurrentUser(): Promise<User | null> {
// => Get auth token from cookie
const authToken = cookies().get('auth_token');
if (!authToken) return null;
// => Verify token and get user (simplified)
// => In production: verify JWT, query database
return {
id: '1',
name: 'Ahmad',
email: 'ahmad@example.com',
role: 'moderator', // => User's role
};
}
export function hasRole(user: User | null, allowedRoles: Role[]): boolean {
// => Check if user has one of allowed roles
if (!user) return false;
return allowedRoles.includes(user.role);
}
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
// => Role requirements for routes
const roleRequirements: Record<string, Role[]> = {
'/admin': ['admin'], // => Admin only
'/moderation': ['admin', 'moderator'], // => Admin or moderator
'/dashboard': ['admin', 'moderator', 'user'], // => Any authenticated role
};
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// => Check if route requires roles
const requiredRoles = Object.entries(roleRequirements).find(([path]) =>
pathname.startsWith(path)
)?.[1];
if (!requiredRoles) {
// => Public route
return NextResponse.next();
}
// => Get user from cookie (simplified - decode JWT in production)
const authToken = request.cookies.get('auth_token');
if (!authToken) {
// => Not authenticated
return NextResponse.redirect(new URL('/login', request.url));
}
// => Check user role (simplified - verify JWT in production)
const userRole = 'moderator'; // => From token
if (!requiredRoles.includes(userRole as any)) {
// => Insufficient permissions
return NextResponse.redirect(new URL('/forbidden', request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ['/admin/:path*', '/moderation/:path*', '/dashboard/:path*'],
};
// app/admin/page.tsx
// => Server Component with role check
import { getCurrentUser, hasRole } from '@/app/lib/auth';
import { redirect } from 'next/navigation';
export default async function AdminPage() {
const user = await getCurrentUser();
// => Server-side role check (defense in depth)
if (!hasRole(user, ['admin'])) {
redirect('/forbidden');
}
return (
<div>
<h1>Admin Dashboard</h1>
<p>Welcome, {user!.name} (Admin)</p>
{/* => Admin-only content */}
<ul>
<li>Manage Users</li>
<li>System Settings</li>
<li>View Logs</li>
</ul>
</div>
);
}
// app/moderation/page.tsx
import { getCurrentUser, hasRole } from '@/app/lib/auth';
import { redirect } from 'next/navigation';
export default async function ModerationPage() {
const user = await getCurrentUser();
if (!hasRole(user, ['admin', 'moderator'])) {
redirect('/forbidden');
}
return (
<div>
<h1>Moderation Panel</h1>
<p>Welcome, {user!.name} ({user!.role})</p>
{/* => Content for admin and moderators */}
<ul>
<li>Review Posts</li>
<li>Manage Comments</li>
</ul>
{/* => Conditional features based on role */}
{user!.role === 'admin' && (
<div>
<h2>Admin Actions</h2>
<button>Ban User</button>
</div>
)}
</div>
);
}Key Takeaway: Implement RBAC with middleware (route protection) and Server Components (component-level checks). Defense in depth with multiple checks.
Expected Output: Admin routes only accessible to admin role. Moderation routes accessible to admin and moderator. Forbidden page for insufficient permissions.
Common Pitfalls: Only checking roles in middleware (bypass via direct component access), or not implementing defense in depth (single point of failure).
Example 74: Advanced API Rate Limiting Patterns
Implement sophisticated rate limiting with different tiers, key strategies, and bypass mechanisms.
// app/lib/rate-limit.ts
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});
// => Different rate limits for different tiers
export const rateLimits = {
// => Free tier: 10 requests per 10 seconds
free: new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(10, "10 s"),
analytics: true,
}),
// => Pro tier: 100 requests per 10 seconds
pro: new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(100, "10 s"),
analytics: true,
}),
// => Enterprise tier: 1000 requests per 10 seconds
enterprise: new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(1000, "10 s"),
analytics: true,
}),
};
// => Get user tier from database/cookie
export async function getUserTier(userId: string): Promise<keyof typeof rateLimits> {
// => Query database for user subscription
// => Simplified: return based on user ID
if (userId === "admin") return "enterprise";
if (userId.startsWith("pro")) return "pro";
return "free";
}
// app/api/data/route.ts
import { NextRequest, NextResponse } from "next/server";
import { rateLimits, getUserTier } from "@/app/lib/rate-limit";
export async function GET(request: NextRequest) {
// => Get user identifier
const userId = request.headers.get("x-user-id") || request.ip || "anonymous";
// => Check for bypass token (for internal services)
const bypassToken = request.headers.get("x-bypass-token");
if (bypassToken === process.env.BYPASS_TOKEN) {
// => Bypass rate limiting for internal services
return NextResponse.json({ data: "Bypassed rate limit" });
}
// => Get user tier
const tier = await getUserTier(userId);
const ratelimit = rateLimits[tier];
// => ratelimit is appropriate limiter for user's tier
// => Check rate limit
const { success, limit, reset, remaining } = await ratelimit.limit(userId);
// => Add rate limit headers to all responses
const headers = {
"X-RateLimit-Limit": limit.toString(),
"X-RateLimit-Remaining": remaining.toString(),
"X-RateLimit-Reset": new Date(reset).toISOString(),
"X-RateLimit-Tier": tier, // => Show user's tier
};
if (!success) {
// => Rate limit exceeded
return NextResponse.json(
{
error: "Rate limit exceeded",
tier,
limit,
reset: new Date(reset),
upgradeUrl: tier === "free" ? "/pricing" : null,
// => Suggest upgrade for free tier users
},
{
status: 429,
headers,
},
);
}
// => Rate limit passed
return NextResponse.json(
{
data: "Your data here",
tier,
remaining,
},
{ headers },
);
}
// app/api/graphql/route.ts
// => More complex rate limiting: per operation type
import { NextRequest, NextResponse } from "next/server";
// => Different limits for different GraphQL operations
const operationLimits = {
query: new Ratelimit({
redis: new Redis({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
}),
limiter: Ratelimit.slidingWindow(100, "60 s"),
}),
mutation: new Ratelimit({
redis: new Redis({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
}),
limiter: Ratelimit.slidingWindow(20, "60 s"), // => Stricter for mutations
}),
};
export async function POST(request: NextRequest) {
const body = await request.json();
// => body is { query: "query { ... }" } or { query: "mutation { ... }" }
// => Detect operation type
const operationType = body.query.trim().startsWith("mutation") ? "mutation" : "query";
const userId = request.headers.get("x-user-id") || "anonymous";
// => Rate limit based on operation type
const limiter = operationLimits[operationType];
const { success, limit, remaining } = await limiter.limit(`${userId}:${operationType}`);
// => Separate counters for queries and mutations
if (!success) {
return NextResponse.json(
{
error: `Rate limit exceeded for ${operationType} operations`,
limit,
},
{ status: 429 },
);
}
// => Process GraphQL request
return NextResponse.json({
data: `Processed ${operationType}`,
remaining,
});
}Key Takeaway: Implement sophisticated rate limiting with tier-based limits, bypass tokens, operation-specific limits, and upgrade suggestions. Use distributed Redis for multi-instance support.
Expected Output: API enforces different rate limits based on user tier (free/pro/enterprise). GraphQL mutations have stricter limits than queries. Internal services bypass limits.
Common Pitfalls: Using same limit for all users (unfair), or not providing upgrade path (frustrates paying users).
Group 10: Advanced Database Patterns
Example 75: Database Transactions with Prisma
Use Prisma transactions for atomic multi-table operations. Ensures data consistency across related operations.
// prisma/schema.prisma
// model User {
// id String @id @default(cuid())
// name String
// balance Float @default(0)
// transactions Transaction[]
// }
//
// model Transaction {
// id String @id @default(cuid())
// userId String
// user User @relation(fields: [userId], references: [id])
// amount Float
// type String // "debit" or "credit"
// createdAt DateTime @default(now())
// }
// app/lib/prisma.ts
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export default prisma;
// app/actions.ts
'use server';
import prisma from './lib/prisma';
export async function transferFunds(
fromUserId: string,
toUserId: string,
amount: number
) {
try {
// => Transaction ensures all-or-nothing execution
const result = await prisma.$transaction(async (tx) => {
// => tx is transactional client (not prisma)
// => All operations in this callback execute atomically
// => Step 1: Get sender's current balance
const sender = await tx.user.findUnique({
where: { id: fromUserId },
});
if (!sender) {
throw new Error('Sender not found');
}
if (sender.balance < amount) {
throw new Error('Insufficient balance');
// => Transaction rolls back, no changes applied
}
// => Step 2: Deduct from sender
await tx.user.update({
where: { id: fromUserId },
data: { balance: { decrement: amount } },
// => balance = balance - amount
});
// => Step 3: Add to receiver
await tx.user.update({
where: { id: toUserId },
data: { balance: { increment: amount } },
// => balance = balance + amount
});
// => Step 4: Record sender transaction
await tx.transaction.create({
data: {
userId: fromUserId,
amount: -amount, // => Negative for debit
type: 'debit',
},
});
// => Step 5: Record receiver transaction
await tx.transaction.create({
data: {
userId: toUserId,
amount: amount, // => Positive for credit
type: 'credit',
},
});
// => Return transaction summary
return {
fromUser: sender.name,
toUserId,
amount,
};
});
// => All operations succeed together or fail together
return {
success: true,
message: `Transferred IDR ${amount.toLocaleString()} successfully`,
details: result,
};
} catch (error) {
// => Transaction failed, all changes rolled back
console.error('Transfer failed:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Transfer failed',
};
}
}
// app/transfer/page.tsx
'use client';
import { useState } from 'react';
import { transferFunds } from '../actions';
export default function TransferPage() {
const [result, setResult] = useState<any>(null);
async function handleSubmit(formData: FormData) {
const fromUserId = formData.get('fromUserId') as string;
const toUserId = formData.get('toUserId') as string;
const amount = parseFloat(formData.get('amount') as string);
const transferResult = await transferFunds(fromUserId, toUserId, amount);
setResult(transferResult);
}
return (
<div>
<h1>Transfer Funds</h1>
<form action={handleSubmit}>
<input type="text" name="fromUserId" placeholder="From User ID" required />
<input type="text" name="toUserId" placeholder="To User ID" required />
<input type="number" name="amount" placeholder="Amount" required />
<button type="submit">Transfer</button>
</form>
{result && (
<div>
{result.success ? (
<>
<p style={{ color: 'green' }}>{result.message}</p>
<p>
From: {result.details.fromUser} → To: {result.details.toUserId}
</p>
<p>Amount: IDR {result.details.amount.toLocaleString()}</p>
</>
) : (
<p style={{ color: 'red' }}>Error: {result.error}</p>
)}
</div>
)}
</div>
);
}Key Takeaway: Use Prisma transactions for atomic multi-table operations. Ensures data consistency (all operations succeed or fail together). Critical for financial operations.
Expected Output: Transferring funds updates both users’ balances and creates transaction records atomically. If any step fails, all changes roll back.
Common Pitfalls: Not using transactions for multi-step operations (data inconsistency), or handling errors outside transaction (partial updates).
Summary
These 25 advanced examples complete the Next.js tutorial:
SSG & ISR: generateStaticParams (pre-render dynamic routes), ISR (time-based/on-demand revalidation), static export (CDN hosting)
Streaming: Suspense boundaries (progressive loading), nested Suspense (granular control), skeleton UI (better UX)
Advanced Caching: unstable_cache (custom cache), React cache() (request deduplication), force-dynamic (opt out of static)
Performance: Image blur placeholders, font optimization (next/font), script optimization (next/script)
SEO: Dynamic OG images, JSON-LD structured data
Deployment: Type-safe env vars, OpenTelemetry monitoring, rate limiting (Upstash)
Advanced Patterns: Server-only code protection, Partial Prerendering (PPR), middleware chaining
Multi-Step Forms & Background Jobs: Multi-step form wizard (session storage), background job processing (queue patterns)
Advanced Authentication: Role-based access control (RBAC with middleware), tier-based rate limiting (free/pro/enterprise)
Advanced Database: Prisma transactions (atomic operations, data consistency)
You’ve now covered 95% of Next.js through 75 examples. You’re ready to build production-grade Next.js applications with confidence.
Next Steps
- Build Projects: Apply patterns to real applications
- Read Official Docs: Deep dive into specific features at nextjs.org/docs
- Join Community: Next.js Discord for questions and discussions
- Stay Updated: Follow @nextjs for framework updates
Congratulations on completing the Next.js By-Example tutorial!