definition. They do not change between requests within a single build session. By instructing a CDN to cache these payloads on the first request, subsequent visitors receive them from a data center geographically proximate to them. Dynamic API routes (/api/*), authentication endpoints, and WebSocket upgrade requests bypass the cache entirely and route directly to the local machine.
Step 1: Select the Tunneling Layer
The tooling landscape has shifted significantly. ngrok deprecated its Cloud Edges feature on December 31, 2025, replacing it with a unified Traffic Policy system (GA mid-2025). ngrok now positions itself as a Developer Gateway, emphasizing API observability, request replays, and automated lifecycle management. For pure static asset caching, Cloudflare Tunnel (cloudflared) and Traforo remain the most direct implementations.
- Cloudflare Tunnel: Establishes outbound, post-quantum encrypted connections. No inbound ports or firewall rules required. Traffic automatically passes through Cloudflare's CDN, WAF, and DDoS protection. Cache behavior is controlled via the dashboard or origin
Cache-Control headers.
- Traforo: Open-source, zero-configuration alternative. Connects to Cloudflare Durable Objects via WebSocket. Enables edge caching with the
-c flag. Supports password protection (--password) and persistent IDs (-t).
- ngrok Traffic Policy: Suitable for teams already invested in the ngrok ecosystem. Requires paid tiers for clean HTML delivery (free tier injects interstitial warning pages). Pricing: Free (1 GB/mo, 1 endpoint), Personal ($8/mo, 5 GB, 1 domain), Pro ($20/mo, 15 GB, load balancing, IP restrictions).
CDNs cannot cache what the origin server explicitly forbids. Development servers default to conservative caching to ensure code changes are immediately visible. You must override this behavior conditionally.
Next.js Implementation
Instead of modifying the core config directly, isolate tunnel-specific headers in a dedicated module. This keeps production builds clean and prevents accidental cache poisoning.
// lib/tunnel-cache-config.ts
import type { NextConfig } from 'next';
export function applyTunnelHeaders(config: NextConfig): NextConfig {
const isTunnelActive = process.env.EDGE_TUNNEL === 'true';
return {
...config,
async headers() {
if (!isTunnelActive) return [];
return [
{
source: '/_next/static/:path*',
headers: [
{ key: 'Cache-Control', value: 'public, max-age=31536000, immutable' }
]
},
{
source: '/public/:path*',
headers: [
{ key: 'Cache-Control', value: 'public, max-age=3600' }
]
},
{
source: '/:path*.html',
headers: [
{ key: 'Cache-Control', value: 'no-cache, no-store, must-revalidate' }
]
}
];
}
};
}
Apply it in next.config.ts:
import { applyTunnelHeaders } from './lib/tunnel-cache-config';
const baseConfig = { /* your existing config */ };
export default applyTunnelHeaders(baseConfig);
Vite Implementation
Vite's development server requires a custom plugin to inject headers before the response is finalized.
// plugins/vite-edge-cache.ts
import type { Plugin } from 'vite';
import { extname } from 'path';
const CACHEABLE_EXTENSIONS = ['.js', '.css', '.woff', '.woff2', '.ttf', '.svg', '.png', '.webp', '.ico'];
export function edgeCachePlugin(): Plugin {
return {
name: 'vite:edge-cache-headers',
configureServer(server) {
server.middlewares.use((req, res, next) => {
const url = req.url || '';
const ext = extname(url).toLowerCase();
if (CACHEABLE_EXTENSIONS.includes(ext)) {
res.setHeader('Cache-Control', 'public, max-age=3600');
} else if (url.endsWith('.html') || url === '/') {
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
}
next();
});
}
};
}
Register in vite.config.ts:
import { defineConfig } from 'vite';
import { edgeCachePlugin } from './plugins/vite-edge-cache';
export default defineConfig({
plugins: [edgeCachePlugin()]
});
Step 3: Enforce WebSocket Bypass
Hot Module Replacement relies on persistent WebSocket connections. If a CDN caches the Upgrade: websocket handshake, HMR fails and stakeholders must manually refresh. The bypass must be explicit.
// edge/worker.ts
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const upgrade = request.headers.get('Upgrade');
// WebSocket connections must never be cached or intercepted
if (upgrade?.toLowerCase() === 'websocket') {
return fetch(request);
}
// Standard HTTP requests follow cache logic
const cache = caches.default;
const cached = await cache.match(request);
if (cached) return cached;
const response = await fetch(request);
// Only cache responses explicitly marked as public
const cacheControl = response.headers.get('Cache-Control') || '';
if (cacheControl.includes('public')) {
ctx.waitUntil(cache.put(request, response.clone()));
}
return response;
}
};
Both cloudflared and Traforo handle WebSocket proxying natively. If you are building a custom edge layer, the bypass is non-negotiable.
Pitfall Guide
| Pitfall | Explanation | Fix |
|---|
| Caching the HTML Entry Point | Frameworks rebuild HTML on every save. Caching it forces stale DOM and breaks HMR. | Explicitly set no-cache for / and *.html routes. |
| Ignoring WebSocket Upgrade Headers | CDNs treat WebSocket handshakes as standard HTTP GET requests, caching the 101 Switching Protocols response. | Route Upgrade: websocket directly to origin. Never apply cache rules to these paths. |
| Over-Caching in Development | Aggressive max-age values mask recent code changes, causing confusion during live demos. | Gate caching behind an environment flag (EDGE_TUNNEL=true). Disable in local-only runs. |
| Exposing Unscoped Ports | Tunneling 0.0.0.0 or all local services leaks internal APIs, debug endpoints, and database ports. | Bind the tunnel strictly to the dev server port (e.g., :3000 or :5173). |
| Relying on Free-Tier Interstitials | ngrok's free plan injects a browser warning page for HTML traffic to combat phishing. Breaks client demos. | Upgrade to Personal/Pro tier, or switch to Cloudflare/Traforo for clean HTML delivery. |
| Cache Invalidation Blind Spots | Framework rebuilds generate new content hashes, but edge nodes may retain old references if headers are misconfigured. | Leverage content-addressed filenames. Set immutable only on hashed chunks. Use max-age=3600 for non-hashed public assets. |
| Missing Cache Hit Monitoring | Developers assume caching works without verifying edge behavior, leading to false confidence. | Monitor CDN dashboard hit ratios. Aim for >85% static asset cache hits during preview sessions. |
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Enterprise team with existing Cloudflare stack | Cloudflare Tunnel (cloudflared) | Native CDN integration, WAF/DDoS included, enterprise-grade security | $0 (included in Cloudflare plans) |
| Quick stakeholder demo, zero setup | Traforo | Durable Object routing, built-in caching flag, no account required | $0 (open-source) |
| API-heavy apps requiring observability | ngrok Traffic Policy | Request replay, live inspection, automated lifecycle management | $8β$20/mo (paid tiers required for clean HTML) |
| Strict compliance / data residency | Self-hosted edge proxy + custom tunnel | Full control over data flow, audit logging, custom cache invalidation | Infrastructure + engineering overhead |
Configuration Template
Cloudflare Tunnel + Next.js
# ~/.cloudflared/config.yml
tunnel: your-tunnel-id
credentials-file: ~/.cloudflared/your-tunnel-id.json
ingress:
- hostname: preview.yourapp.com
service: http://localhost:3000
- service: http_status:404
// next.config.ts
import { applyTunnelHeaders } from './lib/tunnel-cache-config';
export default applyTunnelHeaders({
reactStrictMode: true,
// other production configs
});
Traforo Quick Launch
# Install globally
npm install -g traforo
# Run with edge caching and password protection
traforo -p 3000 -c --password "secure-preview-2026" -t client-review
Quick Start Guide
- Install the tunnel client: Run
npm install -g traforo or download cloudflared from Cloudflare's dashboard.
- Enable cache headers: Add the environment flag
EDGE_TUNNEL=true to your dev script and apply the framework-specific header configuration.
- Start the tunnel: Execute
traforo -p 3000 -c or cloudflared tunnel run. Copy the generated public URL.
- Verify edge behavior: Open the URL in an incognito window. Check network tab
Cache-Control headers and CDN dashboard hit ratios. Confirm HMR updates propagate without manual refresh.
- Share securely: Append password protection or IP restrictions if distributing to external stakeholders. Monitor cache performance during the session.
This architecture transforms localhost tunneling from a bandwidth-constrained workaround into a production-grade preview mechanism. By decoupling static asset delivery from the origin machine, developers retain instant iteration speed while stakeholders receive a responsive, reliable experience. The trade-off is minimal configuration overhead, which pays dividends in collaboration velocity and deployment cost reduction.