Skip to main content
Advertisement

13.6 Pro Tips — Edge Runtime, Streaming SSR, Turbopack, Monorepo Setup

The advanced features of Next.js 15 go beyond simply building fast websites — they let you build production systems capable of handling millions of users. This chapter covers patterns and optimization techniques that professional engineers use in real-world projects.


Edge Runtime

What is Edge Runtime?

Edge Runtime is an execution environment like Cloudflare Workers or Vercel Edge Functions that runs code at the edge — the location closest to the user. While a traditional Node.js server resides in a specific region (e.g., Seoul), edge functions run at hundreds of PoPs (Points of Presence) around the world.

CategoryNode.js RuntimeEdge Runtime
Startup timeHundreds of ms (cold start)~0ms
LatencyHigh for distant usersUniformly low worldwide
APIsFull Node.js APIWeb API subset
MemoryUnlimited128MB limit
File systemAccessibleNot available

Enabling Edge Runtime

// app/api/geo/route.ts
export const runtime = 'edge'; // Declare Edge Runtime

import { NextRequest } from 'next/server';

export async function GET(request: NextRequest) {
// Access geo info at the edge (Vercel-specific)
const country = request.geo?.country ?? 'KR';
const city = request.geo?.city ?? 'Seoul';
const ip = request.ip ?? '0.0.0.0';

// Different response based on country
const content = getLocalizedContent(country);

return Response.json({
country,
city,
ip,
content,
message: `You're connecting from ${city}!`,
});
}

function getLocalizedContent(country: string) {
const contentMap: Record<string, string> = {
KR: '안녕하세요! 한국 사용자 전용 콘텐츠입니다.',
US: 'Hello! This is US-specific content.',
JP: 'こんにちは!日本のユーザー向けコンテンツです。',
};
return contentMap[country] ?? 'Hello from the edge!';
}

A/B Testing with Edge Middleware

// middleware.ts
import { NextRequest, NextResponse } from 'next/server';

export const config = {
matcher: ['/landing/:path*'],
};

export function middleware(request: NextRequest) {
const url = request.nextUrl.clone();

// Cookie-based bucket assignment (consistent user experience)
let bucket = request.cookies.get('ab-bucket')?.value;

if (!bucket) {
// New user: 50/50 assignment
bucket = Math.random() < 0.5 ? 'a' : 'b';
}

// Bucket A: original landing page
// Bucket B: rewrite to new landing page
if (bucket === 'b') {
url.pathname = url.pathname.replace('/landing', '/landing-v2');
}

const response = NextResponse.rewrite(url);

// Save cookie (30 days)
response.cookies.set('ab-bucket', bucket, {
maxAge: 60 * 60 * 24 * 30,
sameSite: 'lax',
});

// Add header for analytics
response.headers.set('x-ab-bucket', bucket);

return response;
}

Using KV Store at the Edge (Cloudflare)

// app/api/cache/route.ts
export const runtime = 'edge';

import { NextRequest } from 'next/server';

// Cloudflare KV type (bound via wrangler.toml)
declare const CACHE: KVNamespace;

export async function GET(request: NextRequest) {
const key = request.nextUrl.searchParams.get('key');
if (!key) {
return Response.json({ error: 'Key is required' }, { status: 400 });
}

// Look up cache in KV
const cached = await CACHE.get(key, 'json');
if (cached) {
return Response.json({ data: cached, source: 'cache' });
}

// Cache miss: fetch from origin
const data = await fetchFromOrigin(key);

// Store in KV (TTL: 1 hour)
await CACHE.put(key, JSON.stringify(data), { expirationTtl: 3600 });

return Response.json({ data, source: 'origin' });
}

async function fetchFromOrigin(key: string) {
// Query actual data source
return { key, value: `data-for-${key}`, timestamp: Date.now() };
}

Streaming SSR

What is Streaming?

Traditional SSR waits until all data is ready before sending the HTML in a single response. Streaming SSR progressively sends HTML as each piece is ready, allowing users to see content sooner.

Traditional SSR:  [Wait 3s for data] → [Send full HTML] → [Show page]
Streaming: [Send shell immediately] → [Stream chunks as ready] → [Progressive display]

Implementing Streaming with Suspense

// app/dashboard/page.tsx
import { Suspense } from 'react';
import { UserProfile, UserProfileSkeleton } from './UserProfile';
import { RecentOrders, OrdersSkeleton } from './RecentOrders';
import { Analytics, AnalyticsSkeleton } from './Analytics';
import { Recommendations, RecommendationsSkeleton } from './Recommendations';

export default function DashboardPage() {
return (
<div className="grid grid-cols-12 gap-4 p-6">
{/* Shows immediately — static content */}
<header className="col-span-12">
<h1 className="text-2xl font-bold">Dashboard</h1>
</header>

{/* Fast query — loads first */}
<div className="col-span-4">
<Suspense fallback={<UserProfileSkeleton />}>
<UserProfile />
</Suspense>
</div>

{/* Medium-speed query */}
<div className="col-span-8">
<Suspense fallback={<OrdersSkeleton />}>
<RecentOrders />
</Suspense>
</div>

{/* Slow query — loads last */}
<div className="col-span-12">
<Suspense fallback={<AnalyticsSkeleton />}>
<Analytics />
</Suspense>
</div>

{/* AI recommendations — slowest */}
<div className="col-span-12">
<Suspense fallback={<RecommendationsSkeleton />}>
<Recommendations />
</Suspense>
</div>
</div>
);
}

Skeleton UI Implementation

// app/dashboard/UserProfile.tsx
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
import Image from 'next/image';

// Actual component (slow DB query)
export async function UserProfile() {
const session = await auth();
const user = await db.user.findUnique({
where: { id: session!.userId },
include: { _count: { select: { orders: true } } },
});

return (
<div className="bg-white rounded-xl p-6 shadow-sm">
<Image
src={user!.avatar}
alt={user!.name}
width={64}
height={64}
className="rounded-full"
/>
<h2 className="mt-3 text-lg font-semibold">{user!.name}</h2>
<p className="text-gray-500">{user!.email}</p>
<div className="mt-4 pt-4 border-t">
<span className="text-sm text-gray-600">
Total orders: <strong>{user!._count.orders}</strong>
</span>
</div>
</div>
);
}

// Skeleton (shown instantly)
export function UserProfileSkeleton() {
return (
<div className="bg-white rounded-xl p-6 shadow-sm animate-pulse">
<div className="w-16 h-16 bg-gray-200 rounded-full" />
<div className="mt-3 h-5 bg-gray-200 rounded w-32" />
<div className="mt-2 h-4 bg-gray-200 rounded w-48" />
<div className="mt-4 pt-4 border-t">
<div className="h-4 bg-gray-200 rounded w-24" />
</div>
</div>
);
}

Streaming API Route

// app/api/stream/route.ts
import { OpenAI } from 'openai';

const openai = new OpenAI();

export async function POST(request: Request) {
const { prompt } = await request.json();

// Create OpenAI stream
const stream = await openai.chat.completions.create({
model: 'gpt-4o',
messages: [{ role: 'user', content: prompt }],
stream: true,
});

// Convert to ReadableStream and stream to client
const readable = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder();

for await (const chunk of stream) {
const text = chunk.choices[0]?.delta?.content ?? '';
if (text) {
controller.enqueue(encoder.encode(text));
}
}

controller.close();
},
});

return new Response(readable, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Transfer-Encoding': 'chunked',
},
});
}
// app/components/StreamingChat.tsx
'use client';

import { useState } from 'react';

export function StreamingChat() {
const [response, setResponse] = useState('');
const [loading, setLoading] = useState(false);

async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const prompt = formData.get('prompt') as string;

setLoading(true);
setResponse('');

try {
const res = await fetch('/api/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt }),
});

const reader = res.body!.getReader();
const decoder = new TextDecoder();

while (true) {
const { done, value } = await reader.read();
if (done) break;
setResponse(prev => prev + decoder.decode(value));
}
} finally {
setLoading(false);
}
}

return (
<div className="max-w-2xl mx-auto p-6">
<form onSubmit={handleSubmit} className="flex gap-2 mb-4">
<input
name="prompt"
placeholder="Enter your question..."
className="flex-1 border rounded-lg px-4 py-2"
/>
<button
type="submit"
disabled={loading}
className="bg-blue-600 text-white px-6 py-2 rounded-lg disabled:opacity-50"
>
{loading ? 'Generating...' : 'Send'}
</button>
</form>
{response && (
<div className="bg-gray-50 rounded-lg p-4 whitespace-pre-wrap">
{response}
{loading && <span className="animate-pulse"></span>}
</div>
)}
</div>
);
}

Turbopack

What is Turbopack?

Turbopack is a Rust-based bundler designed to replace Webpack. It is enabled by default for the development server (next dev) in Next.js 15 and delivers dramatic build speed improvements for large projects.

ComparisonWebpackTurbopack
LanguageJavaScriptRust
Initial buildBaselineUp to 10x faster
HMRBaselineUp to 700x faster
Incremental buildBaselineUp to 10x faster

Enabling Turbopack

# Enabled by default in Next.js 15 (dev server)
next dev --turbopack

# package.json
{
"scripts": {
"dev": "next dev --turbopack"
}
}

next.config.ts Turbopack Configuration

// next.config.ts
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
experimental: {
turbo: {
// Custom module aliases
resolveAlias: {
'@components': './app/components',
'@lib': './app/lib',
'@hooks': './app/hooks',
},
// File extension resolution order
resolveExtensions: ['.tsx', '.ts', '.jsx', '.js', '.json'],
// Webpack loader replacements
rules: {
'*.svg': {
loaders: ['@svgr/webpack'],
as: '*.js',
},
'*.md': {
loaders: ['raw-loader'],
as: '*.js',
},
},
},
},
};

export default nextConfig;

Measuring Turbopack Performance

# Measure build time
time npm run dev

# Analyze bundle size (production build)
ANALYZE=true npm run build

# Detailed build stats
next build --profile

Monorepo Setup

What is a Monorepo?

A Monorepo is a structure where multiple projects (apps, libraries, packages) are managed in a single Git repository. It makes code sharing, consistent version management, and integrated testing much easier.

my-monorepo/
├── apps/
│ ├── web/ # Main Next.js app
│ ├── admin/ # Admin Next.js app
│ └── mobile/ # React Native app
├── packages/
│ ├── ui/ # Shared UI components
│ ├── config/ # Shared configs (ESLint, TypeScript)
│ └── utils/ # Shared utility functions
└── package.json # Root package.json (workspace config)

Turborepo Setup

# Create a new monorepo
npx create-turbo@latest

# Add to existing project
npm install turbo -D -W
// package.json (root)
{
"name": "my-monorepo",
"private": true,
"workspaces": [
"apps/*",
"packages/*"
],
"scripts": {
"dev": "turbo dev",
"build": "turbo build",
"lint": "turbo lint",
"test": "turbo test",
"type-check": "turbo type-check"
},
"devDependencies": {
"turbo": "^2.0.0"
}
}
// turbo.json
{
"$schema": "https://turborepo.org/schema.json",
"ui": "tui",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "!.next/cache/**", "dist/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"lint": {
"dependsOn": ["^lint"]
},
"test": {
"dependsOn": ["^build"],
"outputs": ["coverage/**"]
},
"type-check": {
"dependsOn": ["^build"]
}
}
}

Shared UI Package Setup

// packages/ui/src/Button.tsx
import { ButtonHTMLAttributes, forwardRef } from 'react';
import { cn } from './utils';

interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
size?: 'sm' | 'md' | 'lg';
loading?: boolean;
}

export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ variant = 'primary', size = 'md', loading, className, children, disabled, ...props }, ref) => {
const variants = {
primary: 'bg-blue-600 text-white hover:bg-blue-700',
secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200',
ghost: 'text-gray-600 hover:bg-gray-100',
danger: 'bg-red-600 text-white hover:bg-red-700',
};

const sizes = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-base',
lg: 'px-6 py-3 text-lg',
};

return (
<button
ref={ref}
disabled={disabled || loading}
className={cn(
'rounded-lg font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2',
'disabled:opacity-50 disabled:cursor-not-allowed',
variants[variant],
sizes[size],
className
)}
{...props}
>
{loading ? (
<span className="flex items-center gap-2">
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Loading...
</span>
) : (
children
)}
</button>
);
}
);

Button.displayName = 'Button';
// packages/ui/package.json
{
"name": "@myapp/ui",
"version": "0.0.1",
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"lint": "eslint src/",
"type-check": "tsc --noEmit"
},
"peerDependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
}

Using Shared Packages in a Next.js App

// apps/web/package.json
{
"name": "@myapp/web",
"dependencies": {
"@myapp/ui": "*",
"@myapp/utils": "*",
"next": "^15.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
}
// apps/web/next.config.ts
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
// Transpile monorepo packages
transpilePackages: ['@myapp/ui', '@myapp/utils'],
};

export default nextConfig;
// apps/web/app/page.tsx
import { Button } from '@myapp/ui';
import { formatDate } from '@myapp/utils';

export default function HomePage() {
return (
<main>
<h1>Home</h1>
<p>Today: {formatDate(new Date())}</p>
<Button variant="primary" size="lg">
Get Started
</Button>
</main>
);
}

Advanced Optimization Patterns

1. Partial Prerendering (PPR)

// next.config.ts
const nextConfig: NextConfig = {
experimental: {
ppr: 'incremental', // Incremental PPR adoption
},
};
// app/product/[id]/page.tsx
import { Suspense } from 'react';
import { unstable_noStore as noStore } from 'next/cache';

// Static shell — served immediately
export default function ProductPage({ params }: { params: { id: string } }) {
return (
<div>
{/* Static portion: served instantly from CDN */}
<ProductLayout>
{/* Dynamic portions: filled in via streaming */}
<Suspense fallback={<PriceSkeleton />}>
<DynamicPrice productId={params.id} />
</Suspense>
<Suspense fallback={<StockSkeleton />}>
<DynamicStock productId={params.id} />
</Suspense>
</ProductLayout>
</div>
);
}

// Dynamic component — real-time data on every request
async function DynamicPrice({ productId }: { productId: string }) {
noStore(); // Do not cache this component
const price = await fetchRealTimePrice(productId);
return <span className="text-2xl font-bold">{price.toLocaleString()}</span>;
}

2. Server Actions Optimization

// app/actions/cart.ts
'use server';

import { revalidatePath } from 'next/cache';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';

export async function addToCart(productId: string, quantity: number) {
const session = await auth();
if (!session) throw new Error('Login required');

// Immediate write for optimistic update support
await db.cartItem.upsert({
where: {
userId_productId: {
userId: session.userId,
productId,
},
},
update: { quantity: { increment: quantity } },
create: { userId: session.userId, productId, quantity },
});

// Revalidate related paths
revalidatePath('/cart');
revalidatePath('/products');

return { success: true };
}
// app/components/AddToCartButton.tsx
'use client';

import { useOptimistic, useTransition } from 'react';
import { addToCart } from '@/app/actions/cart';

interface CartButtonProps {
productId: string;
initialCount: number;
}

export function AddToCartButton({ productId, initialCount }: CartButtonProps) {
const [optimisticCount, addOptimistic] = useOptimistic(
initialCount,
(state, amount: number) => state + amount
);
const [isPending, startTransition] = useTransition();

function handleClick() {
startTransition(async () => {
// Optimistic update: reflect in UI immediately
addOptimistic(1);
// Actual server request
await addToCart(productId, 1);
});
}

return (
<button
onClick={handleClick}
disabled={isPending}
className="bg-blue-600 text-white px-6 py-2 rounded-lg"
>
Add to Cart ({optimisticCount})
</button>
);
}

3. Bundle Size Optimization

// Dynamic imports for code splitting
import dynamic from 'next/dynamic';

// Heavy editor — client-only, loaded on demand
const RichTextEditor = dynamic(
() => import('@/components/RichTextEditor'),
{
ssr: false, // No server rendering needed
loading: () => <div className="h-64 bg-gray-100 rounded animate-pulse" />,
}
);

// Chart library — loaded when entering viewport
const AnalyticsChart = dynamic(
() => import('@/components/AnalyticsChart'),
{ ssr: false }
);

// Date library — server-side only
async function formatDates(dates: Date[]) {
const { format } = await import('date-fns/format');
const { ko } = await import('date-fns/locale/ko');
return dates.map(d => format(d, 'PPP', { locale: ko }));
}

Pro Tips Summary

Tip 1: Request Deduplication

// app/lib/api.ts
// Leverage React's automatic fetch deduplication
// Even if multiple components call the same fetch, only one network request is made

async function getUser(id: string) {
// Next.js automatically memoizes fetch calls with the same URL + options
return fetch(`/api/users/${id}`, { cache: 'no-store' }).then(r => r.json());
}

// Both UserCard and UserStats call getUser — only one network request happens
async function UserCard({ userId }: { userId: string }) {
const user = await getUser(userId);
return <div>{user.name}</div>;
}

async function UserStats({ userId }: { userId: string }) {
const user = await getUser(userId);
return <div>Posts: {user.postCount}</div>;
}

Tip 2: Parallel Data Fetching

// app/dashboard/page.tsx
// Bad example — sequential (slow)
async function BadDashboard() {
const user = await getUser(); // 100ms
const orders = await getOrders(); // 200ms
const analytics = await getAnalytics(); // 300ms
// Total: 600ms
}

// Good example — parallel (fast)
async function GoodDashboard() {
// Run all concurrently with Promise.all
const [user, orders, analytics] = await Promise.all([
getUser(), // 100ms
getOrders(), // 200ms
getAnalytics(), // 300ms
]);
// Total: 300ms (limited by the slowest)

return (
<div>
<UserSection user={user} />
<OrdersSection orders={orders} />
<AnalyticsSection analytics={analytics} />
</div>
);
}

Tip 3: Error Boundaries and Fallback Strategy

// app/dashboard/error.tsx
'use client';

import { useEffect } from 'react';
import { Button } from '@myapp/ui';

interface ErrorProps {
error: Error & { digest?: string };
reset: () => void;
}

export default function DashboardError({ error, reset }: ErrorProps) {
useEffect(() => {
// Report to error monitoring service
console.error('Dashboard error:', error);
// Sentry.captureException(error);
}, [error]);

return (
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
<h2 className="text-xl font-semibold text-red-600">An error occurred</h2>
<p className="text-gray-600">{error.message}</p>
{error.digest && (
<code className="text-xs text-gray-400">Error code: {error.digest}</code>
)}
<Button onClick={reset} variant="primary">
Try Again
</Button>
</div>
);
}

Tip 4: Type-Safe Routing

// app/lib/routes.ts
// Type-safe route builder
export const routes = {
home: () => '/',
blog: {
list: () => '/blog',
post: (slug: string) => `/blog/${slug}`,
tag: (tag: string) => `/blog/tag/${tag}`,
},
product: {
list: () => '/products',
detail: (id: string) => `/products/${id}`,
edit: (id: string) => `/products/${id}/edit`,
},
api: {
users: {
list: () => '/api/users',
byId: (id: string) => `/api/users/${id}`,
},
},
} as const;

// Usage:
// <Link href={routes.blog.post('hello-world')}>Blog Post</Link>
// fetch(routes.api.users.byId('123'))

Tip 5: Setting a Performance Budget

// next.config.ts
const nextConfig: NextConfig = {
experimental: {
// Warn on build when budget is exceeded
bundlePagesRouterDependencies: true,
},

// Bundle size limits (bytes)
// Generates warnings when exceeded
onDemandEntries: {
maxInactiveAge: 25 * 1000,
pagesBufferLength: 2,
},
};

// Post-build analysis script
// package.json: "analyze": "ANALYZE=true next build"

Production Readiness Checklist

## Performance
- [ ] Lighthouse score 90+ (Performance, Accessibility, SEO)
- [ ] Core Web Vitals: LCP < 2.5s, FID < 100ms, CLS < 0.1
- [ ] Images: use next/image, WebP/AVIF formats
- [ ] Fonts: use next/font, prevent layout shift
- [ ] Bundle size: initial JS < 100KB (gzip)

## Security
- [ ] Security headers configured (CSP, HSTS, X-Frame-Options)
- [ ] Environment variable validation (zod)
- [ ] API rate limiting implemented
- [ ] CSRF protection (built into Server Actions)
- [ ] Input validation (both server and client)

## Reliability
- [ ] Error boundaries (error.tsx) on all pages
- [ ] Loading states (loading.tsx, Suspense)
- [ ] Custom 404/500 pages
- [ ] Monitoring (Sentry, Vercel Analytics)

## SEO
- [ ] Metadata API in use (title, description, OG)
- [ ] robots.txt and sitemap.xml generated
- [ ] Structured data (JSON-LD)
- [ ] Canonical URL configured
Advertisement