Mastering Next.js Production: Security, SEO, Observability & Architecture
Go beyond functionality. This guide focuses on the critical pillars of enterprise web development: securing applications against XSS/CSRF, maximizing search engine visibility with JSON-LD and Metadata, implementing observability pipelines, and mastering advanced patterns like Streaming, Suspense, and Micro-frontends.
Security
Content Security Policy (CSP)
CSP is a security layer that helps prevent XSS attacks by controlling which resources can be loaded on your pages. In Next.js, you configure CSP via headers in next.config.js or middleware, defining allowed sources for scripts, styles, images, and other resources.
// next.config.js module.exports = { async headers() { return [{ source: '/(.*)', headers: [{ key: 'Content-Security-Policy', value: "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline';" }] }] } }
CORS Configuration
CORS (Cross-Origin Resource Sharing) controls which domains can access your API routes. In Next.js API routes, you must manually set CORS headers since there's no built-in CORS middleware.
// app/api/data/route.js export async function GET(request) { return new Response(JSON.stringify({ data: 'hello' }), { headers: { 'Access-Control-Allow-Origin': 'https://trusted-domain.com', 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, Authorization', } }); }
Security Headers
Security headers protect against common web vulnerabilities by instructing browsers on how to handle your content. Essential headers include X-Frame-Options, X-Content-Type-Options, Referrer-Policy, and Permissions-Policy.
// next.config.js const securityHeaders = [ { key: 'X-Frame-Options', value: 'DENY' }, { key: 'X-Content-Type-Options', value: 'nosniff' }, { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' }, { key: 'X-XSS-Protection', value: '1; mode=block' } ]; module.exports = { async headers() { return [{ source: '/(.*)', headers: securityHeaders }]; } };
XSS Prevention
Cross-Site Scripting (XSS) attacks inject malicious scripts into web pages. React/Next.js automatically escapes JSX content, but you must be careful with dangerouslySetInnerHTML and user-generated content—always sanitize before rendering.
// ❌ DANGEROUS - Never do this <div dangerouslySetInnerHTML={{ __html: userInput }} /> // ✅ SAFE - Use a sanitization library import DOMPurify from 'dompurify'; function SafeHTML({ content }) { const sanitized = DOMPurify.sanitize(content); return <div dangerouslySetInnerHTML={{ __html: sanitized }} />; } // ✅ SAFEST - Let React escape automatically <div>{userInput}</div>
CSRF Protection
Cross-Site Request Forgery tricks users into performing unwanted actions. Protect API routes by implementing CSRF tokens, validating the Origin header, and using SameSite cookies for session management.
// middleware.js import { NextResponse } from 'next/server'; export function middleware(request) { if (request.method !== 'GET') { const origin = request.headers.get('origin'); const allowedOrigins = ['https://yourdomain.com']; if (!origin || !allowedOrigins.includes(origin)) { return new NextResponse('Forbidden', { status: 403 }); } } return NextResponse.next(); } // Use SameSite cookies // Set-Cookie: session=abc123; SameSite=Strict; Secure; HttpOnly
Authentication Best Practices
Use established libraries like NextAuth.js (Auth.js), implement secure session management with HTTP-only cookies, enforce HTTPS, add rate limiting, and never store sensitive tokens in localStorage or expose them to client-side JavaScript.
┌─────────────────────────────────────────────────────────┐
│ Authentication Flow │
├─────────────────────────────────────────────────────────┤
│ Client Server Provider │
│ │ │ │ │
│ │──── Login Request ──►│ │ │
│ │ │──── OAuth/Verify ──►│ │
│ │ │◄─── Token/User ─────│ │
│ │◄── HttpOnly Cookie ──│ │ │
│ │ │ │ │
│ │── API Request ──────►│ │ │
│ │ (Cookie auto-sent) │ │ │
│ │◄── Protected Data ───│ │ │
└─────────────────────────────────────────────────────────┘
// app/api/auth/[...nextauth]/route.js import NextAuth from 'next-auth'; import GitHub from 'next-auth/providers/github'; export const { handlers, auth } = NextAuth({ providers: [GitHub], session: { strategy: 'jwt' }, cookies: { sessionToken: { options: { httpOnly: true, secure: true, sameSite: 'lax' }}} });
API Route Security
Secure API routes by validating authentication, implementing rate limiting, validating request methods, sanitizing inputs, and handling errors gracefully without leaking sensitive information.
// app/api/protected/route.js import { auth } from '@/auth'; import { rateLimit } from '@/lib/rate-limit'; export async function POST(request) { // 1. Rate limiting const limiter = await rateLimit(request); if (!limiter.success) { return Response.json({ error: 'Too many requests' }, { status: 429 }); } // 2. Authentication const session = await auth(); if (!session) { return Response.json({ error: 'Unauthorized' }, { status: 401 }); } // 3. Input validation const body = await request.json(); const validated = schema.safeParse(body); if (!validated.success) { return Response.json({ error: 'Invalid input' }, { status: 400 }); } // 4. Process request return Response.json({ data: 'success' }); }
Environment Variable Security
Next.js exposes only variables prefixed with NEXT_PUBLIC_ to the browser; all others remain server-side only. Never prefix sensitive keys (API secrets, database URLs) with NEXT_PUBLIC_, and use .env.local for local development secrets.
┌──────────────────────────────────────────────────────────┐
│ Environment Variable Visibility │
├────────────────────────┬─────────────────────────────────┤
│ Variable │ Available In │
├────────────────────────┼─────────────────────────────────┤
│ DATABASE_URL │ Server only (API routes, SSR) │
│ API_SECRET_KEY │ Server only │
│ NEXT_PUBLIC_APP_URL │ Both server AND browser ⚠️ │
│ NEXT_PUBLIC_GA_ID │ Both server AND browser ⚠️ │
└────────────────────────┴─────────────────────────────────┘
# .env.local (git-ignored) DATABASE_URL=postgresql://user:pass@host/db # ✅ Server only API_SECRET=super_secret_key # ✅ Server only NEXT_PUBLIC_API_URL=https://api.example.com # ⚠️ Exposed to client
Secrets Management
For production, never commit secrets to git; use secret management services like Vercel Environment Variables, AWS Secrets Manager, HashiCorp Vault, or Google Secret Manager, and rotate secrets regularly.
// For Vercel: Use dashboard or CLI // $ vercel env add DATABASE_URL production // For AWS Secrets Manager integration import { SecretsManager } from '@aws-sdk/client-secrets-manager'; async function getSecret(secretName) { const client = new SecretsManager({ region: 'us-east-1' }); const response = await client.getSecretValue({ SecretId: secretName }); return JSON.parse(response.SecretString); } // Cache secrets at build time or startup, not per-request let cachedSecrets = null; export async function getSecrets() { if (!cachedSecrets) { cachedSecrets = await getSecret('my-app/production'); } return cachedSecrets; }
SQL Injection Prevention
Always use parameterized queries or ORMs like Prisma/Drizzle that handle escaping automatically. Never concatenate user input directly into SQL strings—this is the #1 database security vulnerability.
// ❌ VULNERABLE - SQL Injection const query = `SELECT * FROM users WHERE id = ${userId}`; // NEVER! // ✅ SAFE - Parameterized query import { sql } from '@vercel/postgres'; const { rows } = await sql`SELECT * FROM users WHERE id = ${userId}`; // ✅ SAFE - Using Prisma ORM const user = await prisma.user.findUnique({ where: { id: userId } // Prisma handles escaping }); // ✅ SAFE - Using prepared statements import postgres from 'postgres'; const sql = postgres(process.env.DATABASE_URL); const users = await sql`SELECT * FROM users WHERE email = ${email}`;
Input Validation
Validate all user inputs on both client and server sides using schema validation libraries like Zod. Never trust client-side validation alone—always re-validate on the server in API routes and Server Actions.
// lib/schemas.js import { z } from 'zod'; export const userSchema = z.object({ email: z.string().email(), age: z.number().min(18).max(120), username: z.string().min(3).max(20).regex(/^[a-zA-Z0-9_]+$/) }); // app/api/users/route.js export async function POST(request) { const body = await request.json(); const result = userSchema.safeParse(body); if (!result.success) { return Response.json({ errors: result.error.flatten().fieldErrors }, { status: 400 }); } // result.data is now typed and validated await createUser(result.data); }
Output Sanitization
Sanitize data before sending to clients, especially when including user-generated content in responses. Remove sensitive fields, escape HTML entities when needed, and use allow-lists rather than deny-lists for data exposure.
// lib/sanitize.js export function sanitizeUser(user) { // Allow-list approach: only expose safe fields const { id, name, email, avatar, createdAt } = user; return { id, name, email, avatar, createdAt }; // Excludes: password, apiKey, internalNotes, etc. } // app/api/users/[id]/route.js export async function GET(request, { params }) { const user = await db.user.findUnique({ where: { id: params.id }}); // Never send raw database objects return Response.json(sanitizeUser(user)); } // For HTML content in responses import { encode } from 'html-entities'; const safeContent = encode(userProvidedContent);
Analytics and Monitoring
Web Vitals Reporting
Web Vitals measure real user experience: LCP (loading), FID/INP (interactivity), and CLS (visual stability). Next.js provides built-in support via the useReportWebVitals hook to capture and send these metrics to your analytics service.
// app/components/WebVitals.jsx 'use client'; import { useReportWebVitals } from 'next/web-vitals'; export function WebVitals() { useReportWebVitals((metric) => { // metric: { name, value, id, rating } console.log(metric); // Send to analytics fetch('/api/analytics', { method: 'POST', body: JSON.stringify({ name: metric.name, // LCP, FID, CLS, TTFB, INP value: metric.value, rating: metric.rating // 'good', 'needs-improvement', 'poor' }) }); }); return null; } // app/layout.js export default function RootLayout({ children }) { return <html><body><WebVitals />{children}</body></html>; }
┌─────────────────────────────────────────────────────────┐
│ Core Web Vitals Targets │
├──────────┬───────────────┬──────────────┬───────────────┤
│ Metric │ Good │ Needs Work │ Poor │
├──────────┼───────────────┼──────────────┼───────────────┤
│ LCP │ ≤ 2.5s │ ≤ 4.0s │ > 4.0s │
│ INP │ ≤ 200ms │ ≤ 500ms │ > 500ms │
│ CLS │ ≤ 0.1 │ ≤ 0.25 │ > 0.25 │
└──────────┴───────────────┴──────────────┴───────────────┘
Analytics Integration
Next.js supports various analytics through Script component for optimal loading, Server Components for server-side tracking, and route handlers for custom event collection. Place tracking scripts strategically to minimize performance impact.
// app/layout.js import Script from 'next/script'; export default function RootLayout({ children }) { return ( <html> <body> {children} {/* Load analytics after page becomes interactive */} <Script src="https://analytics.example.com/script.js" strategy="afterInteractive" /> {/* Or lazy load for non-critical analytics */} <Script src="https://optional-analytics.com/script.js" strategy="lazyOnload" /> </body> </html> ); }
Google Analytics
Integrate GA4 using the @next/third-parties package or Next.js Script component. Track page views automatically with route changes, and send custom events from client components for user interactions.
// Option 1: @next/third-parties (recommended) // app/layout.js import { GoogleAnalytics } from '@next/third-parties/google'; export default function RootLayout({ children }) { return ( <html> <body> {children} <GoogleAnalytics gaId="G-XXXXXXXXXX" /> </body> </html> ); } // Track custom events // components/Button.jsx 'use client'; import { sendGAEvent } from '@next/third-parties/google'; export function TrackableButton() { return ( <button onClick={() => sendGAEvent('event', 'button_click', { category: 'engagement', label: 'hero_cta' })}> Click Me </button> ); }
Custom Analytics
Build your own analytics by capturing events on the client, batching them, and sending to your API routes. Store in your database or forward to data warehouses for complete data ownership.
// lib/analytics.js 'use client'; class Analytics { constructor() { this.queue = []; this.flushInterval = setInterval(() => this.flush(), 5000); } track(event, properties = {}) { this.queue.push({ event, properties, timestamp: Date.now(), url: window.location.href, userAgent: navigator.userAgent }); if (this.queue.length >= 10) this.flush(); } async flush() { if (this.queue.length === 0) return; const events = [...this.queue]; this.queue = []; await fetch('/api/analytics', { method: 'POST', body: JSON.stringify({ events }), keepalive: true // Ensures delivery on page unload }); } } export const analytics = new Analytics(); // Usage: analytics.track('purchase', { productId: '123', price: 99.99 });
Performance Monitoring
Monitor app performance using Vercel Analytics, OpenTelemetry integration, or custom solutions. Track server response times, database queries, and third-party API calls to identify bottlenecks.
// instrumentation.js (Next.js instrumentation hook) export async function register() { if (process.env.NEXT_RUNTIME === 'nodejs') { const { NodeSDK } = await import('@opentelemetry/sdk-node'); const { SimpleSpanProcessor } = await import('@opentelemetry/sdk-trace-node'); const sdk = new NodeSDK({ serviceName: 'my-nextjs-app', spanProcessor: new SimpleSpanProcessor(exporter) }); sdk.start(); } } // Custom timing in API routes export async function GET() { const start = performance.now(); const data = await fetchData(); const duration = performance.now() - start; console.log(`API took ${duration}ms`); return Response.json(data, { headers: { 'Server-Timing': `db;dur=${duration}` } }); }
Error Tracking (Sentry)
Sentry captures runtime errors on both client and server with stack traces, session replay, and performance monitoring. Use the official @sentry/nextjs SDK which auto-instruments your application.
// sentry.client.config.js import * as Sentry from '@sentry/nextjs'; Sentry.init({ dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, tracesSampleRate: 0.1, replaysSessionSampleRate: 0.1, replaysOnErrorSampleRate: 1.0, integrations: [Sentry.replayIntegration()] }); // sentry.server.config.js import * as Sentry from '@sentry/nextjs'; Sentry.init({ dsn: process.env.SENTRY_DSN, tracesSampleRate: 0.1 }); // Manual error capture export async function POST(request) { try { await riskyOperation(); } catch (error) { Sentry.captureException(error, { extra: { userId: session.userId } }); return Response.json({ error: 'Something went wrong' }, { status: 500 }); } }
Logging
Implement structured logging with libraries like Pino or Winston for production. Log important events, errors, and request metadata in JSON format for easy parsing by log aggregation services.
// lib/logger.js import pino from 'pino'; export const logger = pino({ level: process.env.LOG_LEVEL || 'info', formatters: { level: (label) => ({ level: label }) }, timestamp: pino.stdTimeFunctions.isoTime }); // app/api/orders/route.js import { logger } from '@/lib/logger'; export async function POST(request) { const requestId = crypto.randomUUID(); const log = logger.child({ requestId }); log.info({ action: 'order_started' }); try { const order = await createOrder(data); log.info({ action: 'order_created', orderId: order.id }); return Response.json(order); } catch (error) { log.error({ action: 'order_failed', error: error.message }); throw error; } } // Output: {"level":"info","time":"2024-...","requestId":"abc","action":"order_created","orderId":"123"}
Real User Monitoring (RUM)
RUM collects performance data from actual users' browsers, providing insights into real-world experience across different devices, networks, and geographies. Combine with Web Vitals for comprehensive monitoring.
// lib/rum.js 'use client'; export function initRUM() { // Performance Observer for long tasks const observer = new PerformanceObserver((list) => { for (const entry of list.getEntries()) { sendToAnalytics({ type: entry.entryType, name: entry.name, duration: entry.duration, startTime: entry.startTime }); } }); observer.observe({ entryTypes: ['longtask', 'resource', 'navigation'] }); // Track connection info const connection = navigator.connection; if (connection) { sendToAnalytics({ type: 'connection', effectiveType: connection.effectiveType, // '4g', '3g', etc. downlink: connection.downlink, rtt: connection.rtt }); } }
┌─────────────────────────────────────────────────────────┐
│ RUM Data Collection │
├─────────────────────────────────────────────────────────┤
│ Browser │
│ ├── Performance Timing (navigation, resources) │
│ ├── Web Vitals (LCP, CLS, INP) │
│ ├── Long Tasks (>50ms main thread blocks) │
│ ├── JavaScript Errors │
│ └── User Context (device, connection, geo) │
│ │ │
│ ▼ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ Analytics API │───►│ Data Warehouse │ │
│ └─────────────────┘ └─────────────────┘ │
└─────────────────────────────────────────────────────────┘
Server-side Logging
Log server-side operations in API routes, Server Components, and middleware. Include request context, use correlation IDs for tracing, and integrate with log aggregation services like Datadog, Logtail, or CloudWatch.
// middleware.js import { logger } from '@/lib/logger'; export function middleware(request) { const requestId = request.headers.get('x-request-id') || crypto.randomUUID(); const start = Date.now(); const response = NextResponse.next(); response.headers.set('x-request-id', requestId); // Log after response logger.info({ requestId, method: request.method, path: request.nextUrl.pathname, duration: Date.now() - start, status: response.status, userAgent: request.headers.get('user-agent'), ip: request.ip }); return response; } // For Vercel: logs stream to Vercel Logs // For self-hosted: configure pino transport // pino-transport: { target: '@logtail/pino', options: { sourceToken: '...' }}
Advanced Patterns
Layouts Pattern
Layouts wrap pages and persist across navigation, maintaining state and avoiding re-renders. They nest automatically based on folder structure, enabling shared UI like headers, sidebars, and navigation.
app/
├── layout.js # Root layout (required)
├── dashboard/
│ ├── layout.js # Dashboard layout (wraps all dashboard pages)
│ ├── page.js # /dashboard
│ └── settings/
│ └── page.js # /settings (uses both layouts)
// app/layout.js export default function RootLayout({ children }) { return ( <html> <body> <Header /> {children} <Footer /> </body> </html> ); } // app/dashboard/layout.js export default function DashboardLayout({ children }) { return ( <div className="dashboard"> <Sidebar /> <main>{children}</main> </div> ); } // Resulting structure for /dashboard/settings: // <RootLayout> // <DashboardLayout> // <SettingsPage /> // </DashboardLayout> // </RootLayout>
Loading UI Patterns
Use loading.js files to show instant loading states during navigation. They leverage React Suspense and display immediately while the page content streams in, providing perceived performance improvements.
app/
├── dashboard/
│ ├── loading.js # Shows while page.js loads
│ ├── page.js
│ └── [id]/
│ ├── loading.js
│ └── page.js
// app/dashboard/loading.js export default function Loading() { return ( <div className="loading-container"> <div className="skeleton-header" /> <div className="skeleton-content"> {[...Array(5)].map((_, i) => ( <div key={i} className="skeleton-row" /> ))} </div> </div> ); } // Or use a spinner component export default function Loading() { return <Spinner />; }
┌─────────────────────────────────────────────┐
│ User clicks link │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ loading.js │ ← Shown immediately │
│ │ (skeleton/spin) │ │
│ └────────┬────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ page.js ready │ ← Replaces loading │
│ │ (actual content)│ │
│ └─────────────────┘ │
└─────────────────────────────────────────────┘
Error Handling Patterns
Use error.js for runtime error boundaries and not-found.js for 404 states. Error boundaries catch errors in their subtree and allow recovery without crashing the entire app.
// app/dashboard/error.js 'use client'; // Must be client component export default function Error({ error, reset }) { useEffect(() => { // Log to error reporting service console.error(error); }, [error]); return ( <div className="error-container"> <h2>Something went wrong!</h2> <button onClick={() => reset()}>Try again</button> </div> ); } // app/dashboard/[id]/not-found.js export default function NotFound() { return ( <div> <h2>Dashboard Not Found</h2> <Link href="/dashboard">Return to Dashboard</Link> </div> ); } // Trigger not-found from server component import { notFound } from 'next/navigation'; async function Page({ params }) { const data = await getData(params.id); if (!data) notFound(); return <div>{data.title}</div>; }
Parallel Data Fetching
Fetch independent data sources simultaneously using Promise.all to reduce total loading time. This prevents sequential waterfall fetches and maximizes server resource utilization.
// ❌ Sequential: ~600ms total async function Page() { const user = await getUser(); // 200ms const posts = await getPosts(); // 200ms const comments = await getComments(); // 200ms return <div>...</div>; } // ✅ Parallel: ~200ms total async function Page() { const [user, posts, comments] = await Promise.all([ getUser(), // 200ms ─┐ getPosts(), // 200ms ─┼─ All run simultaneously getComments() // 200ms ─┘ ]); return <div>...</div>; }
Sequential: Parallel:
────────────► ─────►
[User 200ms] [User 200ms]
[Posts 200ms] [Posts 200ms]
[Comm 200ms] [Comm 200ms]
──────────────────►─────►
Total: 600ms Total: 200ms
Sequential Data Fetching
Use sequential fetching when each request depends on the previous one's result. This is necessary for dependent data but should be minimized by restructuring data relationships where possible.
// When data truly depends on previous results async function Page({ params }) { // Must get user first to know their organization const user = await getUser(params.id); // Need user's org to fetch permissions const permissions = await getPermissions(user.organizationId); // Need permissions to fetch allowed projects const projects = await getProjects(permissions.allowedProjectIds); return <Dashboard user={user} projects={projects} />; } // Optimize by fetching what you can in parallel async function OptimizedPage({ params }) { const user = await getUser(params.id); // These both only depend on user const [permissions, profile] = await Promise.all([ getPermissions(user.organizationId), getProfile(user.id) ]); return <Dashboard user={user} permissions={permissions} profile={profile} />; }
Waterfall Prevention
Prevent request waterfalls by fetching data at the component that needs it (not parent), using parallel fetches, and leveraging React's ability to dedupe identical requests during a single render pass.
// ❌ Waterfall: Parent fetches for children sequentially async function Parent() { const data = await fetchParentData(); return <Child id={data.childId} />; // Child waits for parent } // ✅ Better: Parallel component fetching with Suspense function Page() { return ( <> <Suspense fallback={<Skeleton />}> <UserInfo /> {/* Fetches own data */} </Suspense> <Suspense fallback={<Skeleton />}> <UserPosts /> {/* Fetches own data in parallel */} </Suspense> </> ); } async function UserInfo() { const user = await getUser(); // Request deduped if called elsewhere return <div>{user.name}</div>; } async function UserPosts() { const posts = await getPosts(); // Starts immediately, parallel to UserInfo return <PostList posts={posts} />; }
Streaming Patterns
Streaming progressively sends HTML to the browser as it's generated, showing content before the entire page is ready. Next.js streams automatically with Server Components and Suspense boundaries.
// app/dashboard/page.js import { Suspense } from 'react'; export default function Dashboard() { return ( <div> {/* Immediate - streamed first */} <h1>Dashboard</h1> {/* Streams when ready */} <Suspense fallback={<CardSkeleton />}> <SlowStats /> </Suspense> {/* Can stream independently */} <Suspense fallback={<TableSkeleton />}> <SlowDataTable /> </Suspense> </div> ); } async function SlowStats() { const stats = await getStats(); // 2 second API call return <StatsCards data={stats} />; }
Time: 0ms────200ms────1000ms────2000ms────────►
┌──────────────────────────────────────┐
│ <h1>Dashboard</h1> │ ← Sent immediately
│ <CardSkeleton /> │
│ <TableSkeleton /> │
└──────────────────────────────────────┘
│
▼ Stats ready (1s)
┌──────────────────────────────────────┐
│ <h1>Dashboard</h1> │
│ <StatsCards data={...} /> │ ← Streamed in
│ <TableSkeleton /> │
└──────────────────────────────────────┘
│
▼ Table ready (2s)
┌──────────────────────────────────────┐
│ <h1>Dashboard</h1> │
│ <StatsCards data={...} /> │
│ <DataTable data={...} /> │ ← Streamed in
└──────────────────────────────────────┘
Suspense Patterns
Suspense enables showing fallback UI while async operations complete. Use multiple Suspense boundaries for independent loading states, and nest them for granular control over what streams when.
// Granular Suspense boundaries function ProductPage({ id }) { return ( <div> {/* Critical - loads first */} <Suspense fallback={<HeaderSkeleton />}> <ProductHeader id={id} /> </Suspense> <div className="grid"> {/* These load independently */} <Suspense fallback={<ImageSkeleton />}> <ProductImages id={id} /> </Suspense> <Suspense fallback={<DetailsSkeleton />}> <ProductDetails id={id} /> </Suspense> </div> {/* Non-critical - can load last */} <Suspense fallback={<ReviewsSkeleton />}> <ProductReviews id={id} /> </Suspense> </div> ); } // Nested Suspense for progressive detail <Suspense fallback={<ShellSkeleton />}> <Shell> <Suspense fallback={<ContentSkeleton />}> <Content /> </Suspense> </Shell> </Suspense>
Server and Client Composition
Server Components are the default and can import Client Components, but not vice versa. Pass Server Components as children to Client Components for optimal composition, keeping interactive bits client-side while data fetching stays on the server.
// ✅ Server Component (default) - can fetch data // app/dashboard/page.js import ClientTabs from './ClientTabs'; import ServerData from './ServerData'; export default async function Page() { const data = await fetchData(); // Runs on server return ( <ClientTabs> {/* Server Component passed as children to Client Component */} <ServerData data={data} /> </ClientTabs> ); } // ClientTabs.js 'use client'; export default function ClientTabs({ children }) { const [tab, setTab] = useState('a'); return ( <div> <button onClick={() => setTab('a')}>Tab A</button> <button onClick={() => setTab('b')}>Tab B</button> <div>{children}</div> {/* Server-rendered content */} </div> ); } // ❌ Cannot import Server Component in Client Component 'use client'; import ServerComponent from './ServerComponent'; // Not allowed
┌────────────────────────────────────────────────────┐
│ Component Composition Rules │
├────────────────────────────────────────────────────┤
│ │
│ Server Component │
│ ├── Can import: Server Components ✅ │
│ ├── Can import: Client Components ✅ │
│ ├── Can: fetch data, access backend ✅ │
│ └── Cannot: use hooks, browser APIs ❌ │
│ │
│ Client Component ('use client') │
│ ├── Can import: Client Components ✅ │
│ ├── Can receive: Server Components as props ✅ │
│ ├── Can: use hooks, event handlers ✅ │
│ └── Cannot: import Server Components ❌ │
│ │
└────────────────────────────────────────────────────┘
Component Organization
Organize components by type (server/client) and purpose (features/shared). Co-locate related files, use barrel exports sparingly (can hurt tree-shaking), and maintain clear naming conventions.
src/
├── app/ # Routes
│ ├── (marketing)/ # Route group
│ │ ├── page.js
│ │ └── layout.js
│ └── dashboard/
│ ├── page.js
│ ├── loading.js
│ └── _components/ # Route-specific components
│ └── DashboardCard.js
│
├── components/ # Shared components
│ ├── ui/ # Generic UI (Button, Modal)
│ │ ├── Button.js
│ │ └── Modal.js
│ ├── forms/ # Form components
│ │ └── InputField.js
│ └── providers/ # Context providers
│ └── ThemeProvider.js
│
├── lib/ # Utilities
│ ├── utils.js
│ └── api.js
│
└── hooks/ # Custom hooks
└── useDebounce.js
Feature-based Structure
Organize code by feature/domain rather than type for larger applications. Each feature folder contains its own components, hooks, utils, and tests, making the codebase more modular and team-friendly.
src/
├── features/
│ ├── auth/
│ │ ├── components/
│ │ │ ├── LoginForm.js
│ │ │ └── SignupForm.js
│ │ ├── hooks/
│ │ │ └── useAuth.js
│ │ ├── api/
│ │ │ └── auth.js
│ │ ├── utils/
│ │ │ └── validation.js
│ │ └── index.js # Public exports
│ │
│ ├── products/
│ │ ├── components/
│ │ │ ├── ProductCard.js
│ │ │ └── ProductList.js
│ │ ├── hooks/
│ │ │ └── useProducts.js
│ │ ├── api/
│ │ │ └── products.js
│ │ └── index.js
│ │
│ └── checkout/
│ └── ...
│
├── app/ # Routes (thin, import from features)
│ └── products/
│ └── page.js # import { ProductList } from '@/features/products'
│
└── shared/ # Cross-feature shared code
├── components/
├── hooks/
└── utils/
Shared Components
Create a design system of reusable components that maintain consistency across the app. Use composition patterns, proper prop typing, and forward refs for maximum flexibility.
// components/ui/Button.js import { forwardRef } from 'react'; import { cn } from '@/lib/utils'; const variants = { primary: 'bg-blue-500 text-white hover:bg-blue-600', secondary: 'bg-gray-200 text-gray-900 hover:bg-gray-300', danger: 'bg-red-500 text-white hover:bg-red-600' }; const sizes = { sm: 'px-3 py-1.5 text-sm', md: 'px-4 py-2 text-base', lg: 'px-6 py-3 text-lg' }; export const Button = forwardRef(({ variant = 'primary', size = 'md', className, children, ...props }, ref) => ( <button ref={ref} className={cn( 'rounded font-medium transition-colors', variants[variant], sizes[size], className )} {...props} > {children} </button> )); // Usage <Button variant="danger" size="lg" onClick={handleDelete}> Delete </Button>
Micro-frontends with Next.js
Implement micro-frontends using Module Federation or Next.js Multi-zones, allowing independent teams to develop and deploy separate parts of the application. Use shared dependencies carefully to avoid bundle bloat.
// next.config.js - Multi-zones approach module.exports = { async rewrites() { return [ // Main app handles root // Shop team's separate Next.js app { source: '/shop/:path*', destination: 'https://shop.example.com/shop/:path*' }, // Blog team's separate Next.js app { source: '/blog/:path*', destination: 'https://blog.example.com/blog/:path*' } ]; } };
┌────────────────────────────────────────────────────────┐
│ Micro-frontend Architecture │
├────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Main App │ │ Shop App │ │ Blog App │ │
│ │ (Team A) │ │ (Team B) │ │ (Team C) │ │
│ │ Next.js │ │ Next.js │ │ Next.js │ │
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
│ │ │ │ │
│ ┌──────▼────────────────▼────────────────▼──────┐ │
│ │ Shared Components │ │
│ │ (npm package or Module Fed) │ │
│ │ Design System, Auth, Analytics │ │
│ └───────────────────────────────────────────────┘ │
│ │ │
│ ┌─────────────────────▼─────────────────────────┐ │
│ │ Edge/CDN Layer (Vercel) │ │
│ │ Routes requests to appropriate app │ │
│ └───────────────────────────────────────────────┘ │
│ │
│ https://example.com/ → Main App │
│ https://example.com/shop/* → Shop App │
│ https://example.com/blog/* → Blog App │
│ │
└────────────────────────────────────────────────────────┘