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.
| Category | Node.js Runtime | Edge Runtime |
|---|---|---|
| Startup time | Hundreds of ms (cold start) | ~0ms |
| Latency | High for distant users | Uniformly low worldwide |
| APIs | Full Node.js API | Web API subset |
| Memory | Unlimited | 128MB limit |
| File system | Accessible | Not 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.
| Comparison | Webpack | Turbopack |
|---|---|---|
| Language | JavaScript | Rust |
| Initial build | Baseline | Up to 10x faster |
| HMR | Baseline | Up to 700x faster |
| Incremental build | Baseline | Up 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