Encited logo
Pricing
← Back to blog
Prerender API - Win AI search

Prerender API - Win AI search

October 13, 2025·by Encited

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/html with the page HTML.
  • If prerendering does not apply (static asset, non-HTML request, or browser navigation): returns 304 with Location header pointing to the target URL.
  • If a configured redirect rule matches: returns 301 with Location header set to the redirect target. Middleware should forward the 301 to the end client.

Example middleware handling for all three statuses:

ts
CopyDownload
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 client
if (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 origin
if (r.status === 304) return fetch(req);
// 200 = rendered HTML
return 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 the Location header 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:

  • 301 only — 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-store so 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.

1

Create a new Worker

Open Cloudflare dashboard → choose a "Hello World" Worker → Deploy → Edit Code

2

Paste the snippet below into the Worker editor

encited.js
CopyDownload
// encited.js (Cloudflare Worker)
export default {
async fetch(req, env) {
// Only handle public GET navigations
if (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();
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 client
if (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 origin
if (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);
},
};
3

Add the API key secret

Under Variables and Secrets, add a secret named LOVABLEHTML_API_KEY or run: wrangler secret put LOVABLEHTML_API_KEY

4

Confirm your DNS record is proxied

Routes need your domain's DNS to be managed in the same Cloudflare account. In DNS → Records, the A or CNAME record for yourdomain.com must show an orange cloud (Proxied), not gray (DNS only). The Worker only runs on traffic that passes through Cloudflare's proxy.

5

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.

6

Set Failure mode

Set to Fail open (proceed) and save. If the Worker ever errors, requests continue straight to your origin instead of failing.

7

Let crawlers through Cloudflare's bot settings

If you use Bot Fight Mode or AI crawler blocking (Security → Bots), allow the crawlers you want pre-rendered. Those protections run before your Worker, so a blocked crawler never reaches it.

8

Deploy the Worker

It can take a couple of minutes to start working.

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.

1

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.

2

Follow the Standard setup

Create the Worker, add the API key secret, and attach the route exactly as described in Standard setup above.

3

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.

If this feels too complicated, our no-code setup can handle it for you with a couple of DNS records.
Set project URL
1

Create a new Worker

Open Cloudflare dashboard → choose a "Hello World" Worker → Deploy → Edit Code

2

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
CopyDownload
// 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 domain
if (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 navigations
if (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();
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 client
if (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 upstream
if (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);
},
};
3

Add the API key secret

Under Variables and Secrets, add a secret named LOVABLEHTML_API_KEY or run: wrangler secret put LOVABLEHTML_API_KEY

4

Deploy the Worker

5

Attach the Worker as a Custom Domain

Go to your Worker → Settings → Domains & Routes → AddCustom domain → enter yourdomain.com. Add a second Custom Domain for www.yourdomain.com if you use www.

6

Wait for DNS and SSL

Cloudflare auto-creates the DNS record and SSL. You'll see a special Worker mapping in your DNS tab; leave it as is.

7

Verify the connection

Wait a couple of minutes for propagation. Hit your custom domain and confirm traffic reaches the Worker (and through it, Lovable).

Custom Domain, not Route

In this mode the Worker is the origin, so there is no proxied DNS record for it to sit behind. Use Custom domain attachment so Cloudflare manages DNS and SSL for you. Routes won't work without an upstream DNS record to intercept.

Vercel Middleware (run before every request)

1

Create middleware.js at the project root

Install @vercel/functions, then save the snippet at the same level as package.json.

2

Set the LOVABLEHTML_API_KEY environment variable

Add the key in your Vercel project environment settings. The snippet reads it at runtime and does not store it in source control.

3

Deploy to Vercel

The middleware runs on the configured matcher for every request.

middleware.js
CopyDownload
// middleware.js (place at the project root next to package.json)
export const config = {
// Use Node.js runtime to access standard Request/Response
runtime: '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/functions
export 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 ones
const headers = {
"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 URL
const r = await fetch(
"https://encited.com/api/prerender/render?url=" +
encodeURIComponent(request.url),
{ headers, redirect: 'manual' }
);
// 301 = configured redirect rule matched - forward to client
if (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 SPA
if (r.status === 304) {
return next();
}
// Return HTML or fall through
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 {
// ignore
}
// Safety fallback: never block the request
return next();
};

Netlify Edge Functions (attach to /*)

1

Create the Edge Function file

At netlify/edge-functions/lovablehtml.js

2

Set the LOVABLEHTML_API_KEY environment variable

Add the key in your Netlify site environment settings with the Functions scope. The Edge Function reads it through Netlify.env.

3

Deploy to Netlify

The edge function runs on every request and lets Encited classify public HTML GET requests.

encited.js
CopyDownload
// 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 = {
'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 client
if (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 origin
if (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",
};

Errors

  • 401 missing_api_key / invalid_api_key
  • 403 domain_not_owned
  • 200 text/html on success
  • 301 with Location header when a configured redirect rule matches
  • 304 with Location header when prerendering not applicable

Best practices

  • Keep API keys secret; rotate/revoke when compromised.
  • Always send Accept: text/html for 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:

json
CopyDownload
{
"domain": "example.com",
"path": "/pricing",
"prewarm": true
}

Response:

json
CopyDownload
{ "ok": true, "prewarmed": 1 }

Example:

bash
CopyDownload
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:

json
CopyDownload
{
"domain": "example.com",
"paths": ["/", "/pricing", "/blog/post"],
"prewarm": true
}

Response:

json
CopyDownload
{ "ok": true, "prewarmed": 3 }

Example:

bash
CopyDownload
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:

json
CopyDownload
{ "domain": "example.com" }

Response:

json
CopyDownload
{ "ok": true, "accepted": true }

Example:

bash
CopyDownload
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 domain belongs to the authenticated user.
  • prewarm: true deletes 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:

Get discovered anywhere search happens

Readable, citable, outranking pages.

Avatar
How can we help?
Get instant answers to your questions or leave a message for an engineer will reach out
Ask AI about Encited
See our docs
Contact support
Leave a message
We'll get back to you soon
Avatar
Ask AI about Encited
Team is also here to help
Thinking
Preview
Drop an image to attach
Powered by ReplyMaven