Prerender API - Win AI search
Prerender your CSR pages into HTML using Cloudflare Worker, Vercel or Netlify functions.
You must own the target domain (added to your Encited account). The API verifies domain ownership before rendering.
Auth header
Send your API key with one of these headers:
x-lovablehtml-api-key: <API_KEY>Authorization: Bearer <API_KEY>
Create/manage keys in the dashboard.
Render endpoint
GET /api/prerender/render?url=<ENCODED_URL>
Headers (one of):
x-lovablehtml-api-key: <API_KEY>Authorization: Bearer <API_KEY>
Behavior
- If prerendering applies: returns
200 text/htmlwith the page HTML. - If prerendering does not apply (static asset, non-HTML request, or browser navigation): returns
304withLocationheader pointing to the target URL. - If a configured redirect rule matches: returns
301withLocationheader set to the redirect target. Middleware should forward the301to the end client.
Example middleware handling for all three statuses:
const r = await fetch("https://encited.com/api/prerender/render?url=" +encodeURIComponent(req.url),{ headers: { "x-lovablehtml-api-key": API_KEY, accept: "text/html" } },);// 301 = configured redirect rule matched, forward to the end clientif (r.status === 301) {return new Response(null, {status: 301,headers: {Location: r.headers.get("location") ?? "/","Cache-Control": "no-store",},});}// 304 = not pre-rendered, pass through to originif (r.status === 304) return fetch(req);// 200 = rendered HTMLreturn new Response(await r.text(), {headers: { "content-type": "text/html; charset=utf-8" },});
Notes
- Static assets (e.g.
.css,.js, images, fonts) are never prerendered. Follow theLocationheader or fetch directly. - To ensure HTML rendering, send
Accept: text/html. The endpoint classifies requests similarly to the built-in prerenderer.
Configuring redirects
Configure redirect rules from the /config/routing page in the dashboard. Rules are evaluated by the /render endpoint; on a match it returns 301 with the resolved Location so your middleware can forward it directly to the client.
Behavior:
301only — there is no internal-rewrite (307) mode; matched requests always redirect the end client.- First-match-wins: rules are evaluated top-to-bottom and the first matching rule wins.
- Wildcards:
*matches a single path segment,**matches the rest of the path. - Query strings on the incoming request are preserved by default and appended to the redirect target.
- Destinations may be relative paths or full external
https://...URLs. - Redirect responses include
Cache-Control: no-storeso browsers won't cache them indefinitely.
Cloudflare Workers (run for every request via Route)
Standard setup
For custom managed servers, WordPress, Shopify, BigCommerce and others.
Create a new Worker
Open Cloudflare dashboard → choose a "Hello World" Worker → Deploy → Edit Code
Paste the snippet below into the Worker editor
// encited.js (Cloudflare Worker)export default {async fetch(req, env) {// Only handle public GET navigationsif (req.method !== 'GET') return fetch(req);// Treat missing/empty Accept and bare '*/*' as HTML so crawler tests// (curl without -H, default fetch) still route through prerender.// Asset requests from browsers send specific Accept (e.g. 'text/css,*/*;q=0.1')// so they won't match.const accept = (req.headers.get('accept') || '').trim();const isHtmlRequest = !accept || accept === '*/*' || accept.includes('text/html');if (!isHtmlRequest) return fetch(req);const headers = new Headers();// Add LOVABLEHTML_API_KEY as a secret: Worker -> Settings -> Variables and Secrets.headers.set('x-lovablehtml-api-key', env.LOVABLEHTML_API_KEY);headers.set('accept', 'text/html');const forward = ['accept-language','sec-fetch-mode','sec-fetch-site','sec-fetch-dest','sec-fetch-user','upgrade-insecure-requests','referer','user-agent',];for (const name of forward) {const v = req.headers.get(name);if (v) headers.set(name, v);}try {const r = await fetch('https://encited.com/api/prerender/render?url=' + encodeURIComponent(req.url),{ headers, redirect: 'manual' },);// 301 = configured redirect rule matched - forward to clientif (r.status === 301) {const loc = r.headers.get('location');if (loc) {return new Response(null, {status: 301,headers: { location: loc, 'cache-control': 'no-store' },});}}// 304 = not pre-rendered, pass through to originif (r.status === 304) {return fetch(req);}if (r.status === 200 && (r.headers.get('content-type') || '').includes('text/html')) {const responseHeaders = new Headers(r.headers);for (const name of ['content-encoding', 'content-length', 'transfer-encoding', 'connection', 'keep-alive']) {responseHeaders.delete(name);}responseHeaders.set('content-type', 'text/html; charset=utf-8');return new Response(r.body, { status: 200, headers: responseHeaders });}} catch {// Prerender unreachable → fall through so visitors still get the site}return fetch(req);},};
Add a route to your Worker
Go to your Worker → Settings → Domains & Routes → Add Route → enter yourdomain.com/*. If your site is reachable on both yourdomain.com and www.yourdomain.com, enter *yourdomain.com/* instead so both are covered.
Hosting on a subdomain? Use blog.yourdomain.com/* for just that subdomain, or *.yourdomain.com/* to cover every subdomain.
Deploy the Worker
It can take a couple of minutes to start working.
Common mistakes
- DNS record on the gray cloud. Routes only fire on proxied traffic. In DNS → Records, the A or CNAME record for
yourdomain.commust show the orange cloud (Proxied), not gray (DNS only). - Failure mode left on the default. Set the route's Failure mode to Fail open (proceed) so a Worker error sends requests straight to your origin instead of failing.
- Bot Fight Mode blocking crawlers. Bot Fight Mode and AI crawler blocking (Security → Bots) run before your Worker, so a blocked crawler never reaches it. Allow the crawlers you want pre-rendered.
Setup for BigCommerce
For BigCommerce stores on a custom domain.
BigCommerce manages your hostname through its own Cloudflare account (Cloudflare for SaaS). With BigCommerce's default DNS instructions, Cloudflare hands requests directly to BigCommerce, so they never reach your Worker even when a route is attached. The fix below works on any Cloudflare plan.
Point your DNS at BigCommerce with a proxied CNAME
In your Cloudflare zone, the store hostname (www or a subdomain) must be a CNAME to shops.mybigcommerce.com, set to Proxied (orange cloud). Cloudflare then routes traffic through your zone first, so your Worker runs before BigCommerce serves the page.
This only works for www and subdomains; an A record on the apex bypasses your Worker. If your store runs on the apex domain, redirect the apex to www and attach the Worker route to www.
Follow the Standard setup
Create the Worker, paste the snippet, and attach the route exactly as described in Standard setup above.
Verify the Worker is in the path
Run the checks in How to Test below. A crawler request to your store should come back with the x-lovablehtml-render-cache header. If it's missing, the DNS record is usually still an A record or not proxied, so traffic is skipping your zone.
Setup for Lovable, Base44, GHL and others
For domains connected to a Lovable, GHL AI Studio, Base44 (or similar) hosted project.
Create a new Worker
Open Cloudflare dashboard → choose a "Hello World" Worker → Deploy → Edit Code
Paste the snippet below into the Worker editor
Edit the two constants at the top: LOVABLE_UPSTREAM (your Lovable hosted URL, set above) and PUBLIC_HOST (your custom domain).
// encited.js (Cloudflare Worker - Custom Domain mode)// Use this when the Worker is attached as a Custom Domain in Cloudflare and// you need to forward non-prerendered traffic to your Lovable hosted URL.// CHANGE THIS: your Lovable hosted URL (e.g. https://yourapp.lovable.app)const LOVABLE_UPSTREAM = 'https://yourapp.lovable.app';// CHANGE THIS: your public custom domain (e.g. yourdomain.com)const PUBLIC_HOST = 'yourdomain.com';function isRedirect(status) {return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;}async function forwardToUpstream(req) {const upstreamBase = new URL(LOVABLE_UPSTREAM);const upstreamUrl = new URL(req.url);upstreamUrl.protocol = upstreamBase.protocol;upstreamUrl.hostname = upstreamBase.hostname;upstreamUrl.port = upstreamBase.port;const h = new Headers(req.headers);h.set('Host', upstreamBase.hostname);h.set('X-Forwarded-Host', PUBLIC_HOST);h.set('X-Forwarded-Proto', 'https');h.delete('cf-connecting-ip');h.delete('x-forwarded-for');h.delete('forwarded');const isGetLike = req.method === 'GET' || req.method === 'HEAD';const upstreamReq = new Request(upstreamUrl.toString(), {method: req.method,headers: h,body: isGetLike ? undefined : req.body,redirect: 'manual',});const resp = await fetch(upstreamReq);// Rewrite redirects so users stay on your custom domainif (isRedirect(resp.status)) {const loc = resp.headers.get('Location') || '';let newLoc = loc.replaceAll(upstreamBase.hostname, PUBLIC_HOST);newLoc = newLoc.replace(/^http:\/\//i, 'https://');const newHeaders = new Headers(resp.headers);if (loc) newHeaders.set('Location', newLoc);return new Response(resp.body, { status: resp.status, headers: newHeaders });}return resp;}export default {async fetch(req, env) {// Only handle public GET navigationsif (req.method !== 'GET') return forwardToUpstream(req);// Treat missing/empty Accept and bare '*/*' as HTML so crawler tests// (curl without -H, default fetch) still route through prerender.// Asset requests from browsers send specific Accept (e.g. 'text/css,*/*;q=0.1')// so they won't match.const accept = (req.headers.get('accept') || '').trim();const isHtmlRequest = !accept || accept === '*/*' || accept.includes('text/html');if (!isHtmlRequest) return forwardToUpstream(req);const headers = new Headers();// Add LOVABLEHTML_API_KEY as a secret: Worker -> Settings -> Variables and Secrets.headers.set('x-lovablehtml-api-key', env.LOVABLEHTML_API_KEY);headers.set('accept', 'text/html');const forward = ['accept-language','sec-fetch-mode','sec-fetch-site','sec-fetch-dest','sec-fetch-user','upgrade-insecure-requests','referer','user-agent',];for (const name of forward) {const v = req.headers.get(name);if (v) headers.set(name, v);}try {const r = await fetch('https://encited.com/api/prerender/render?url=' + encodeURIComponent(req.url),{ headers, redirect: 'manual' },);// 301 = configured redirect rule matched - forward to clientif (r.status === 301) {const loc = r.headers.get('location');if (loc) {return new Response(null, {status: 301,headers: { location: loc, 'cache-control': 'no-store' },});}}// 304 = not pre-rendered, fall through to upstreamif (r.status === 304) {return forwardToUpstream(req);}if (r.status === 200 && (r.headers.get('content-type') || '').includes('text/html')) {const responseHeaders = new Headers(r.headers);for (const name of ['content-encoding', 'content-length', 'transfer-encoding', 'connection', 'keep-alive']) {responseHeaders.delete(name);}responseHeaders.set('content-type', 'text/html; charset=utf-8');return new Response(r.body, { status: 200, headers: responseHeaders });}} catch {// Prerender unreachable → fall through so visitors still get the site}return forwardToUpstream(req);},};
Deploy the Worker
Attach the Worker as a Custom Domain
Go to your Worker → Settings → Domains & Routes → Add → Custom domain → enter yourdomain.com. Add a second Custom Domain for www.yourdomain.com if you use www.
Common mistakes
- Adding a Route instead of a Custom Domain. In this mode the Worker is the origin, so there is no proxied DNS record for a Route to intercept. Use Custom domain attachment so Cloudflare manages DNS and SSL for you.
- Testing before DNS and SSL are ready. Cloudflare auto-creates the DNS record and certificate after you attach the domain; the special Worker mapping in your DNS tab is expected. Give it a few minutes.
- Custom domain still set as primary in Lovable. A redirect loop after the Worker takes over means the domain is still set as primary. Unset it and your domain keeps working exactly as before.
Vercel Middleware (run before every request)
Create middleware.js at the project root
Install @vercel/functions, then save the snippet at the same level as package.json.
// middleware.js (place at the project root next to package.json)export const config = {// Use Node.js runtime to access standard Request/Responseruntime: 'nodejs',// Run on all paths except static assets (customize for your app)matcher: ['/((?!_some-static-path|favicon.ico).*)',// You can also be explicit:// '/:path*'],};import { next } from "@vercel/functions"; // <- npm install @vercel/functionsexport default async function middleware(request) {// Treat missing/empty Accept and bare '*/*' as HTML so crawler tests// (curl without -H, default fetch) still route through prerender.// Asset requests from browsers send specific Accept (e.g. 'text/css,*/*;q=0.1')// so they won't match.const accept = (request.headers.get("accept") || "").trim();const isHtmlRequest =!accept || accept === "*/*" || accept.includes("text/html");// 2. If it's not a GET request or not HTML, pass through (e.g. API routes)if (request.method !== "GET" || !isHtmlRequest) {return next();}try {// Forward relevant headers and add custom onesconst headers = {// Set LOVABLEHTML_API_KEY in your Vercel project's environment variables."x-lovablehtml-api-key": process.env.LOVABLEHTML_API_KEY,accept: "text/html","accept-language": request.headers.get("accept-language") || "","sec-fetch-mode": request.headers.get("sec-fetch-mode") || "","sec-fetch-site": request.headers.get("sec-fetch-site") || "","sec-fetch-dest": request.headers.get("sec-fetch-dest") || "","sec-fetch-user": request.headers.get("sec-fetch-user") || "","upgrade-insecure-requests":request.headers.get("upgrade-insecure-requests") || "",referer: request.headers.get("referer") || "","user-agent": request.headers.get("user-agent") || "",};// Call Encited prerender service with the full URLconst r = await fetch("https://encited.com/api/prerender/render?url=" +encodeURIComponent(request.url),{ headers, redirect: 'manual' });// 301 = configured redirect rule matched - forward to clientif (r.status === 301) {const loc = r.headers.get("location");if (loc) {return new Response(null, {status: 301,headers: { location: loc, "cache-control": "no-store" },});}}// not pre-rendered, regular browser routing - pass through to SPAif (r.status === 304) {return next();}// Return HTML or fall throughif (r.status === 200 && (r.headers.get("content-type") || "").includes("text/html")) {const responseHeaders = new Headers(r.headers);for (const name of ["content-encoding", "content-length", "transfer-encoding", "connection", "keep-alive"]) {responseHeaders.delete(name);}responseHeaders.set("content-type", "text/html; charset=utf-8");return new Response(r.body, { status: 200, headers: responseHeaders });}} catch {// ignore}// Safety fallback: never block the requestreturn next();};
Deploy to Vercel
The middleware runs on the configured matcher for every request.
Netlify Edge Functions (attach to /*)
Create the Edge Function file
At netlify/edge-functions/lovablehtml.js
// netlify/edge-functions/lovablehtml.js (Netlify Edge Function)export default async (request, context) => {// Only handle public GET navigations.// Treat missing/empty Accept and bare '*/*' as HTML so crawler tests// (curl without -H, default fetch) still route through prerender.// Asset requests from browsers send specific Accept (e.g. 'text/css,*/*;q=0.1')// so they won't match.const accept = (request.headers.get('accept') || '').trim();const isHtmlRequest = !accept || accept === '*/*' || accept.includes('text/html');if (request.method !== 'GET' || !isHtmlRequest) return context.next();const headers = {// Set LOVABLEHTML_API_KEY in your Netlify site's environment variables (Functions scope).'x-lovablehtml-api-key': Netlify.env.get('LOVABLEHTML_API_KEY'),accept: 'text/html','accept-language': request.headers.get('accept-language') || '','sec-fetch-mode': request.headers.get('sec-fetch-mode') || '','sec-fetch-site': request.headers.get('sec-fetch-site') || '','sec-fetch-dest': request.headers.get('sec-fetch-dest') || '','sec-fetch-user': request.headers.get('sec-fetch-user') || '','upgrade-insecure-requests': request.headers.get('upgrade-insecure-requests') || '',referer: request.headers.get('referer') || '','user-agent': request.headers.get('user-agent') || '',};try {const r = await fetch('https://encited.com/api/prerender/render?url=' + encodeURIComponent(request.url),{ headers, redirect: 'manual' },);// 301 = configured redirect rule matched - forward to clientif (r.status === 301) {const loc = r.headers.get('location');if (loc) {return new Response(null, {status: 301,headers: { location: loc, 'cache-control': 'no-store' },});}}// 304 = not pre-rendered, pass through to originif (r.status === 304) {return context.next();}if (r.status === 200 && (r.headers.get('content-type') || '').includes('text/html')) {const responseHeaders = new Headers(r.headers);for (const name of ['content-encoding', 'content-length', 'transfer-encoding', 'connection', 'keep-alive']) {responseHeaders.delete(name);}responseHeaders.set('content-type', 'text/html; charset=utf-8');return new Response(r.body, { status: 200, headers: responseHeaders });}} catch {// Prerender unreachable → continue to the existing Netlify request chain}return context.next();};export const config = {path: "/*",onError: "bypass",};
Deploy to Netlify
The edge function runs on every request and lets Encited classify public HTML GET requests.
Errors
401 missing_api_key/invalid_api_key403 domain_not_owned200 text/htmlon success301withLocationheader when a configured redirect rule matches304withLocationheader when prerendering not applicable
Best practices
- Keep API keys secret; rotate/revoke when compromised.
- Always send
Accept: text/htmlfor bots/crawlers to maximize prerender chance.
Cache invalidation endpoints
These endpoints purge prerendered sources of pages for domains you own. Optionally prewarm to immediately re-render. Authentication is the same as the render endpoint (API key header).
POST /api/prerender/cache/invalidate-page-cache
Body:
{"domain": "example.com","path": "/pricing","prewarm": true}
Response:
{ "ok": true, "prewarmed": 1 }
Example:
curl -sS \-X POST \-H "content-type: application/json" \-H "x-lovablehtml-api-key: <API_KEY>" \-d '{"domain":"example.com","path":"/pricing","prewarm":true}' \https://<your-dashboard-host>/api/prerender/cache/invalidate-page-cache
POST /api/prerender/cache/invalidate-paths-cache
Body:
{"domain": "example.com","paths": ["/", "/pricing", "/blog/post"],"prewarm": true}
Response:
{ "ok": true, "prewarmed": 3 }
Example:
curl -sS \-X POST \-H "content-type: application/json" \-H "x-lovablehtml-api-key: <API_KEY>" \-d '{"domain":"example.com","paths":["/","/pricing","/blog/post"],"prewarm":true}' \https://<your-dashboard-host>/api/prerender/cache/invalidate-paths-cache
POST /api/prerender/cache/invalidate-site-cache
Body:
{ "domain": "example.com" }
Response:
{ "ok": true, "accepted": true }
Example:
curl -sS \-X POST \-H "content-type: application/json" \-H "x-lovablehtml-api-key: <API_KEY>" \-d '{"domain":"example.com","prewarm":true}' \https://<your-dashboard-host>/api/prerender/cache/invalidate-site-cache
Notes
- The API validates that the
domainbelongs to the authenticated user. prewarm: truedeletes the old cache and immediately re-renders the path(s).- Common variants (with/without trailing slash) are handled automatically.
API Collections & Integrations
Explore and test the Prerender API using these platforms:
- Postman Collection - Interactive API documentation with request examples
- APIDog Collection - Alternative API testing and documentation
- RapidAPI - Marketplace listing for the Prerendering API
