proxy.ts: Node.js Runtime for Next.js Request Interception
Full filesystem and npm package access in middleware—proxy.ts replaces middleware.ts with Node.js runtime
Next.js 16 introduces proxy.ts to replace middleware.ts, running on Node.js runtime with full filesystem and npm package access. middleware.ts remains available for Edge runtime scenarios but is now deprecated. This clarifies network boundaries: proxy for request interception with full Node.js APIs, middleware for lightweight Edge logic.
Runtime Differences
| Feature | proxy.ts (Node.js) | middleware.ts (Edge) |
|---|---|---|
| Runtime | Node.js (V8 + Node APIs) | Edge (V8 isolates only) |
| npm packages | ✓ All Node.js packages | ✗ Only Edge-compatible |
| Filesystem (fs) | ✓ Full access | ✗ Not available |
| Cold start | ~100-300ms | ~10-50ms |
| Memory limit | 1-10 GB (configurable) | 128 MB (fixed) |
| Execution time | Up to 60s (serverless) | 30s max (Vercel Edge) |
proxy.ts: Full Node.js Access
// proxy.ts
import { NextRequest, NextResponse } from 'next/server';
import Database from 'database-lib'; // ✓ Works (full npm support)
import fs from 'fs'; // ✓ Works (filesystem access)
export default async function proxy(request: NextRequest) {
// Example: Rate limiting with database
const ip = request.ip || 'unknown';
const db = new Database();
const rateLimitCount = await db.getRateLimitCount(ip);
if (rateLimitCount > 100) {
return NextResponse.json(
{ error: 'Rate limit exceeded' },
{ status: 429 }
);
}
await db.incrementRateLimitCount(ip);
return NextResponse.next();
}
export const config = {
matcher: '/api/:path*', // Only run on API routes
};middleware.ts: Edge Runtime Only
// middleware.ts (deprecated but still available)
import { NextRequest, NextResponse } from 'next/server';
export const config = {
runtime: 'edge', // Required for middleware.ts
};
export default function middleware(request: NextRequest) {
// ❌ No filesystem access
// ❌ No Node.js-specific packages
// ✓ Fast cold starts (10-50ms)
// Example: Simple header-based auth
const authHeader = request.headers.get('authorization');
if (!authHeader) {
return NextResponse.redirect(new URL('/login', request.url));
}
return NextResponse.next();
}When to Use Each
Use proxy.ts for:
- Database queries: Rate limiting, session validation, feature flags from DB
- Heavy npm packages: JWT verification with
jsonwebtoken, OAuth libraries - Filesystem operations: Reading config files, loading certificates
- Complex logic: Multi-step validation, external API calls with retries
Use middleware.ts (Edge) for:
- Geolocation routing: Redirect users based on
request.geo - A/B testing: Lightweight cookie-based routing
- Header manipulation: Adding CORS headers, security headers
- Simple auth checks: Token presence validation (not signature verification)
Migration from middleware.ts
Step 1: Rename File
mv middleware.ts proxy.tsStep 2: Remove Edge Runtime Config
// ❌ Remove this (proxy.ts defaults to Node.js)
export const config = {
runtime: 'edge',
};
// ✓ No runtime config needed
export default function proxy(request: NextRequest) {
// ...
}Step 3: Add Node.js Dependencies
// Now you can use Node.js packages
import bcrypt from 'bcrypt'; // ❌ Doesn't work in Edge
import Database from 'database-lib'; // ❌ Doesn't work in Edge
export default async function proxy(request: NextRequest) {
const db = new Database(); // ✓ Works in proxy.ts
const hashedPassword = await bcrypt.hash('password', 10); // ✓ Works
}Edge Cases and Gotchas
1. Cold Start Latency
proxy.ts has slower cold starts than Edge middleware. On low-traffic sites, first request after idle period sees 100-300ms latency:
// proxy.ts (Node.js)
// Cold start: 250ms
// Warm: 5ms
// middleware.ts (Edge)
// Cold start: 15ms
// Warm: 2msSolution: Keep middleware.ts for latency-sensitive operations (geo-routing, A/B tests). Use proxy.ts for complex logic that justifies the cold start cost.
2. Matcher Syntax Unchanged
Both proxy.ts and middleware.ts use the same matcher config:
export const config = {
matcher: [
'/api/:path*', // All API routes
'/((?!_next|static).*)', // All pages except Next.js internals
'/dashboard/:path*', // Specific path prefix
],
};3. Response Modification Limits
Both runtimes cannot modify response bodies after NextResponse.next(). To modify responses, use rewrites or API routes:
// ❌ Cannot modify response body in proxy/middleware
export default async function proxy(request: NextRequest) {
const response = NextResponse.next();
// Cannot modify response.body here
return response;
}
// ✓ Use rewrite to modify via API route
export default async function proxy(request: NextRequest) {
return NextResponse.rewrite(new URL('/api/transform', request.url));
}4. Cookies and Headers in Rewrites
Cookies set in proxy/middleware are not sent to rewritten destinations by default:
export default async function proxy(request: NextRequest) {
const response = NextResponse.rewrite(new URL('/api/data', request.url));
response.cookies.set('session', 'abc123');
// ❌ /api/data does NOT receive this cookie automatically
// ✓ Must manually forward via headers
response.headers.set('X-Forwarded-Cookie', 'session=abc123');
return response;
}Real-World Use Cases
Use Case 1: JWT Authentication with Database Lookup
// proxy.ts
import { NextRequest, NextResponse } from 'next/server';
import jwt from 'jsonwebtoken'; // Node.js package
import Database from '@/lib/db';
export default async function proxy(request: NextRequest) {
const token = request.cookies.get('auth-token')?.value;
if (!token) {
return NextResponse.redirect(new URL('/login', request.url));
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET!);
const db = new Database();
const user = await db.users.findById(decoded.userId);
if (!user || user.banned) {
return NextResponse.redirect(new URL('/login', request.url));
}
// Add user data to request headers (accessible in pages/API routes)
const response = NextResponse.next();
response.headers.set('X-User-Id', user.id);
response.headers.set('X-User-Role', user.role);
return response;
} catch (error) {
return NextResponse.redirect(new URL('/login', request.url));
}
}
export const config = {
matcher: '/dashboard/:path*',
};Use Case 2: Feature Flag Lookup from Redis
// proxy.ts
import { NextRequest, NextResponse } from 'next/server';
import Redis from 'ioredis'; // Node.js-only package
const redis = new Redis(process.env.REDIS_URL!);
export default async function proxy(request: NextRequest) {
const userId = request.cookies.get('user-id')?.value;
if (!userId) {
return NextResponse.next();
}
// Check feature flag from Redis
const hasNewFeature = await redis.get(`feature:${userId}:new-dashboard`);
if (hasNewFeature === '1') {
// Rewrite to new dashboard
return NextResponse.rewrite(new URL('/dashboard-v2', request.url));
}
return NextResponse.next();
}
export const config = {
matcher: '/dashboard',
};Use Case 3: Geo-Routing (Keep in middleware.ts)
// middleware.ts (Edge is better for this)
import { NextRequest, NextResponse } from 'next/server';
export default function middleware(request: NextRequest) {
const country = request.geo?.country || 'US';
// Redirect EU users to GDPR-compliant version
if (['DE', 'FR', 'IT', 'ES'].includes(country)) {
return NextResponse.rewrite(new URL('/eu', request.url));
}
return NextResponse.next();
}
export const config = {
runtime: 'edge', // Fast cold starts for geo-routing
};Debugging proxy.ts
1. Console Logs
export default async function proxy(request: NextRequest) {
console.log('proxy.ts executing for:', request.url);
console.log('Cookies:', request.cookies.getAll());
console.log('Headers:', Object.fromEntries(request.headers));
return NextResponse.next();
}Logs appear in server terminal, not browser console.
2. Request Timing
export default async function proxy(request: NextRequest) {
const start = Date.now();
const response = NextResponse.next();
const duration = Date.now() - start;
response.headers.set('X-Proxy-Duration', `${duration}ms`);
console.log(`proxy.ts took ${duration}ms`);
return response;
}3. Error Handling
export default async function proxy(request: NextRequest) {
try {
// Your proxy logic
return NextResponse.next();
} catch (error) {
console.error('proxy.ts error:', error);
// Return error response (don't crash entire app)
return NextResponse.json(
{ error: 'Proxy error' },
{ status: 500 }
);
}
}Performance Considerations
Minimize Database Queries
proxy.ts runs on every matched request. Expensive DB queries add latency to all pages:
// ❌ Slow (queries DB on every request)
export default async function proxy(request: NextRequest) {
const user = await db.users.findById(userId); // 50ms per request
return NextResponse.next();
}
// ✓ Fast (cache in Redis)
const redis = new Redis();
export default async function proxy(request: NextRequest) {
const cached = await redis.get(`user:${userId}`); // 2ms per request
if (cached) return NextResponse.next();
const user = await db.users.findById(userId);
await redis.set(`user:${userId}`, JSON.stringify(user), 'EX', 300); // 5min cache
return NextResponse.next();
}Limit Matcher Scope
// ❌ Runs on ALL requests (slow)
export const config = {
matcher: '/:path*',
};
// ✓ Runs only on necessary routes (fast)
export const config = {
matcher: ['/api/:path*', '/dashboard/:path*'],
};proxy.ts brings Node.js power to request interception. Use it when you need full npm ecosystem access, database queries, or filesystem operations. Keep middleware.ts (Edge) for latency-sensitive, lightweight operations like geo-routing and simple header manipulation.
Advertisement
Explore these curated resources to deepen your understanding
Official Documentation
Related Insights
Explore related edge cases and patterns
Advertisement