Implementation Guide
Prerendering for Next.js: Setup Guide
Production prerendering for Next.js App Router: bot detection middleware, reverse proxy routing, WAF whitelist, validation.

Article
Implementing prerendering for a Next.js application requires four components: bot detection middleware, reverse proxy routing, WAF whitelist configuration, and snapshot validation. This guide covers all four with production-ready code for Next.js App Router (v13+) and ostr.io as the prerendering service.
ostr.io is a managed prerendering service that delivers deterministic HTML snapshots to search crawlers and AI retrieval systems through proxy-level middleware, without requiring framework rewrites. The middleware patterns below work with any prerendering service; ostr.io-specific configuration is noted where applicable.
Simple Integration: The seo-middleware-nextjs NPM Package
Before wiring bot detection by hand, consider the official NPM package: seo-middleware-nextjs. It is a lightweight Next.js middleware powered by the ostr.io pre-rendering engine, paired with a global CDN and smart caching layer. The package intercepts crawler and social-bot requests at the edge and reroutes them to ostr.io rendering endpoints — fully rendered HTML for bots, zero changes to the existing codebase, and less load on your origin (fewer server cycles and database queries).
It covers both traditional SERPs (Google, Bing, Yahoo) and modern AI agents (ChatGPT, Grok, Perplexity), plus link-preview bots from messaging and social apps (iMessage, Slack, Telegram, WhatsApp, Facebook, X/Twitter). Because pages are served pre-rendered from a CDN, it also improves TTFB, LCP, INP, CLS, and other Web Vitals — and with it, Lighthouse and SEO scores. PWAs, SPAs, styled-components, AMP, and strict Content-Security-Policy setups are supported. The package is written in strict TypeScript and exports all necessary types.
Install it:
npm install seo-middleware-nextjs --saveThen create middleware.ts in /src/ or the root directory of your Next.js project:
// middleware.tsimport { SEOMiddleware } from 'seo-middleware-nextjs'const seoMiddleware = new SEOMiddleware({ // Get credentials from the ostr.io Pre-rendering Panel → Integration Guide auth: 'Basic xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',})export const middleware = seoMiddleware.createMiddleware()export const config = { matcher: '/((?!api|_next/static|_next/image|_next/webpack-hmr|\\.well-known|favicon.ico).*)',}The auth token can also be supplied via the OSTRIO_AUTH, PRERENDER_SERVICE_AUTH, or SPIDERABLE_SERVICE_AUTH environment variable instead of hardcoding it. The constructor accepts options for custom bot lists (botAgents), excluded routes (ignoredPaths), ignore get parameters (keepGetQuery), and static-asset extensions (ignoredExtensions). See full documentation in seo-middleware-nextjs repository.
To speed up snapshot capture, signal readiness from your client bundle: initialize window.IS_RENDERED = false early, then set it to true once the page has finished loading data. See implementation example here.
If the package fits your deployment, — you don't need hand-written middleware in Step 2 and the Nginx routing in Step 3 — skip ahead to WAF whitelisting (Step 4), cache TTL (Step 5), and validation (Steps 7–9). The manual patterns below remain useful when you need full control over bot routing or run a non-standard proxy topology.
Step 1: Identify Which Routes Need Prerendering
Not every route in a Next.js App Router application needs prerendering. Server Components, Static Pages (with generateStaticParams), and ISR pages are already crawler-friendly. Prerendering adds value for:
- Routes using
'use client'components with data fetching (useEffect, React Query, SWR) - Routes with significant client-side state that populates after mount
- Routes using Web Components or Shadow DOM content
- Any route where View Page Source shows significantly less content than the rendered DOM
Check each route category:
# Find all client component filesgrep -r "'use client'" ./app --include="*.tsx" -l# Check which routes use client-side data fetchinggrep -r "useEffect\|useSWR\|useQuery" ./app --include="*.tsx" -lRoutes that are purely Server Components with no client-side data fetching can skip prerendering. Routes with the patterns above need it.

Step 2: Implement Bot Detection Middleware
Next.js middleware runs on every request at the Edge. The bot detection logic intercepts crawler requests and routes them to the prerendering service.
// middleware.tsimport { NextRequest, NextResponse } from 'next/server'const PRERENDERING_SERVICE_URL = process.env.PRERENDERING_SERVICE_URL ?? 'https://ostr.io'const PRERENDERING_TOKEN = process.env.PRERENDERING_API_TOKEN ?? ''// Bot User-Agent patterns — keep updated as new AI crawlers launchconst BOT_UA_REGEX = /googlebot|bingbot|yandexbot|duckduckbot|slurp|baiduspider|sogou|exabot|facebot|ia_archiver|gptbot|claudebot|applebot|anthropic-ai|perplexitybot|bytespider/i// Routes to exclude from prerenderingconst EXCLUDED_PATHS = [ /^\/_next\//, // Next.js internal assets /^\/api\//, // API routes /^\/static\//, // Static files /\.(js|css|png|jpg|jpeg|gif|svg|ico|woff|woff2|ttf|eot)$/i, // Assets]function isBot(userAgent: string): boolean { return BOT_UA_REGEX.test(userAgent)}function isExcluded(pathname: string): boolean { return EXCLUDED_PATHS.some((pattern) => pattern.test(pathname))}export function middleware(req: NextRequest) { const ua = req.headers.get('user-agent') ?? '' const { pathname } = req.nextUrl // Skip non-bot traffic and excluded paths if (!isBot(ua) || isExcluded(pathname)) { return NextResponse.next() } // Build the prerendering request URL const targetUrl = `${PRERENDERING_SERVICE_URL}/render/${req.url}` // Forward to prerendering service with auth header return NextResponse.rewrite(targetUrl, { headers: { 'X-Prerender-Token': PRERENDERING_TOKEN, 'X-Forwarded-For': req.headers.get('x-forwarded-for') ?? req.ip ?? '', }, })}export const config = { matcher: [ // Match all routes except Next.js internals and static assets '/((?!_next/static|_next/image|favicon.ico).*)', ],}Step 3: Configure Nginx Reverse Proxy (Alternative to Middleware)
If your deployment uses Nginx as a reverse proxy (common for non-Vercel deployments), configure bot routing at the Nginx layer:
# /etc/nginx/conf.d/prerendering.confmapmap $http_user_agent $is_bot { default 0; "~*googlebot" 1; "~*bingbot" 1; "~*yandexbot" 1; "~*gptbot" 1; "~*claudebot" 1; "~*applebot" 1; "~*anthropic-ai" 1; "~*perplexitybot" 1;}serverserver { listenlisten 443 ssl; server_nameserver_name yourdomain.com; locationlocation / { # Route bots to prerendering service ifif ($is_bot) { proxy_passproxy_pass https://ostr.io/render/$scheme://$host$request_uri; proxy_set_header X-Prerender-Token $PRERENDERING_TOKEN; proxy_set_header X-Forwarded-For $remote_addr; breakbreak; } # Route users to Next.js app proxy_passproxy_pass http://localhost:3000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }}Step 4: Add ostr.io IP Ranges to WAF Whitelist
Before prerendering traffic can reach your origin, ostr.io's IP ranges must be allowed in your WAF.
Cloudflare:
- Go to Security → WAF → Tools → IP Access Rules
- Add each ostr.io IP range with action: Allow
- Note: Enter each CIDR block separately (Cloudflare does not support bulk import in the UI — use the API for large range lists)
# Cloudflare API bulk allowlistcurl -X POST "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/firewall/access_rules/rules" \ -H "Authorization: Bearer $CF_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{"mode":"whitelist","configuration":{"target":"ip_range","value":"<ostr.io CIDR>"},"notes":"ostr.io prerendering"}'AWS WAF:
# Terraform: AWS WAF IP set for ostr.ioresource "aws_wafv2_ip_set" "ostrio" { name = "ostr-io-prerendering" scope = "CLOUDFRONT" ip_address_version = "IPV4" addresses = [/* ostr.io IP ranges */]}resource "aws_wafv2_web_acl_rule" "allow_ostrio" { name = "AllowOstrio" priority = 1 action { allow {} } statement { ip_set_reference_statement { arn = aws_wafv2_ip_set.ostrio.arn } } visibility_config { cloudwatch_metrics_enabled = true metric_name = "AllowOstrio" sampled_requests_enabled = true }}Count what this step costs you on an ongoing basis, because it is easy to file as a one-time task. The allowlist is a standing contract between two vendors who do not know about each other: your firewall provider classifies traffic, your rendering provider generates it, and you maintain the rule that stops the first from blocking the second. Ranges change, managed rule groups update, and a bot-detection toggle flipped by a colleague breaks the crawler path months later with no alert — the failure is silent by construction, which is why the WAF over-blocking guide exists at all.
There is an architecture where this step does not exist: an edge that owns both the firewall and the crawler-facing render, so no external range needs allowlisting because the rendering traffic never crosses a vendor boundary. Bridge CDN, from the same team as this site, is being built to that shape. It has not launched — no dashboard, no pricing, waitlist only — so it changes nothing about the setup you are doing today. Note it if an edge migration is on your roadmap; otherwise write the Terraform above and put a calendar reminder on re-verifying the ranges.

Step 5: Set Cache TTL, and Handle Freshness With Invalidation
Content types have different freshness requirements, but read your vendor's TTL model before you design around one. On ostr.io, TTL is set per host, not per route, and the documented range is 2 hours to 744 hours (cache docs). Earlier versions of this guide showed a per-pattern TTL config object with 15-minute product TTLs; that configuration does not exist on ostr.io and the sub-hour values are below its documented minimum.
What that means in practice: pick one host-level TTL that is safe for your slowest-changing content, then drive freshness for everything faster with event-driven invalidation rather than with a shorter clock.
// Freshness comes from the publish event, not from a short TTL.// Host TTL: set once, conservatively (e.g. 24h) in the dashboard.// Per-URL refresh: invalidate on change, then re-request so the next// crawler hit is served from cache rather than waiting on a render.export async function onContentPublish(url: string) { // 1. Invalidate the cached snapshot for this URL. // ostr.io keys per-page purges on the X-Prerender-Id response header; // a full purge is rate-limited to once per 2 hours, so never use it here. // 2. Warm: fetch as a crawler so the snapshot is rebuilt now. await fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)', 'Cache-Control': 'no-cache', }, })}Which routes need an invalidation hook:
- Prices, inventory, real-time data — hook every write
- Product and listing pages — hook status and price changes
- Blog posts, docs, static content — hook publish only; the host TTL is enough
- Documentation, legal pages: 24 hours
Step 6: Warm Snapshots on Content Publish
Trigger snapshot regeneration on content publish events. For Next.js, the natural hook is after revalidatePath().
Note what this is not: ostr.io publishes no API endpoint for triggering a render, and earlier revisions of this guide showed a POST https://ostr.io/api/cache-warm call that does not exist. The working pattern is to request your own URL as a crawler, which makes the prerendering layer generate and cache the snapshot on your schedule instead of Googlebot's.
// lib/prerendering.ts//// There is no documented public ostr.io endpoint for triggering a render.// Warming is therefore a fetch of your own URL with a crawler User-Agent:// the prerendering layer sees a crawler request, generates the snapshot, and// caches it — so the next real crawler visit is a cache hit rather than a wait.export async function warmSnapshot(url: string) { try { await fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)', 'Cache-Control': 'no-cache', }, }) } catch { // Non-critical — log but don't throw console.warn(`Snapshot warm failed for ${url}`) }}// In your CMS webhook handler or Server Actionimport { revalidatePath } from 'next/cache'import { warmSnapshot } from '@/lib/prerendering'export async function publishContent(slug: string) { const url = `https://yourdomain.com/blog/${slug}` // Revalidate ISR cache revalidatePath(`/blog/${slug}`) // Warm the crawler-facing snapshot await warmSnapshot(url)}Step 7: Test the Prerendering Configuration
Before going live, verify that:
1. Bots receive prerendered HTML:
# Simulate Googlebot requestcurl -A "Googlebot" https://yourdomain.com/blog/your-post -I# Expected: X-Prerender-Status: hit or X-Prerender-Status: rendered header# If missing: middleware is not routing bot traffic correctly2. Users receive the live Next.js application:
# Simulate user request (no bot UA)curl https://yourdomain.com/blog/your-post -I# Expected: no prerender headers, normal Next.js response3. WAF is not blocking prerendering traffic: Check ostr.io dashboard for WAF-blocked render attempts. Check your WAF logs for blocked requests from ostr.io IP ranges.
4. DOM Consistency Score is above 95%: Use ostr.io's dashboard to compare the rendered snapshot for a representative product page against the live page. DOM Consistency Score should be 95%+.
5. Prerendering.info Checker: Submit your domain to the Prerender Checker and verify that the tool can retrieve a complete prerendered snapshot for your most important pages.
Step 8: Validate JSON-LD and Structured Data
Structured data (JSON-LD) must appear in the prerendered HTML, not be injected by client-side JavaScript. Verify with View Page Source (not DevTools, which shows the rendered DOM):
# Check JSON-LD in prerendered outputcurl -A "Googlebot" https://yourdomain.com/product/your-product | grep -o 'application/ld+json' | wc -l# Should return 1 or moreIf JSON-LD blocks are missing from the prerendered HTML, they are being generated client-side. Move them to Server Components.
Step 9: Monitor with Google Search Console
After going live, monitor these signals in Google Search Console:
- Crawl Stats → Render type: Should shift from "JavaScript" to "HTML" rendering
- Coverage → Not indexed: Should decrease over 4–8 weeks as snapshots reach Googlebot
- URL Inspection: For any page still not indexed, use URL Inspection to fetch and render — verify the prerendered output is complete
Frequently Asked Questions
Yes. Next.js middleware runs on Vercel's Edge Runtime by default. The `NextResponse.rewrite()` call routes bot requests to ostr.io at the edge, before reaching the Next.js application origin. This is the recommended Vercel deployment pattern — it adds zero latency for user traffic.
Exclude authenticated routes from prerendering. If the prerendering service attempts to render a page behind authentication, it receives a login redirect — not the page content. In your middleware, add authenticated route paths to the `EXCLUDED_PATHS` array.
No. Server Components are rendered server-side by Next.js for all requests. For Server Component pages, prerendering adds a caching layer on top of the server-rendered output — the output should be identical. The primary value of prerendering for App Router sites is for pages with Client Components that fetch data after mount.
Run the ostr.io service locally or use the hosted service with a tunnel (ngrok, Cloudflare Tunnel) to expose your local Next.js server. Test using `curl -A "Googlebot" https://your-tunnel-url/your-page`. The prerendering pre-launch checklist covers the full local testing workflow. !Raster matrix diagram of operational levers, risks, and validation checks for Prerendering Implementation Guide for Next.js: Setup, Routing, and Validation.
Explore the canonical guides
Editorial trust
Written by prerender Editorial · Engineering Team. We build and run pre-rendering infrastructure for more than 200 engineering teams, which is where the numbers and code samples on this page come from.
Last updated . Editorial scope and review policy: About prerender.info.