How Webflow pre-rendering works
Webflow publishes your site on a webflow.io staging subdomain. A Cloudflare Worker attached to your custom domain serves pre-rendered HTML to crawlers and forwards everyone else to your published Webflow site.
Crawler GET requests for HTML receive the rendered snapshot from Encited. Browser visits, assets, and any failed render calls are proxied straight to your webflow.io origin.
Prerequisites
- An Encited account and API key (Settings → API Keys).
- Your custom domain added to a Cloudflare account (the free plan works).
- The published project URL, e.g. my-app.webflow.io.
Set up pre-rendering for Webflow
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: WEBFLOW_UPSTREAM (your Webflow 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 Webflow hosted URL.// CHANGE THIS: your Webflow hosted URL (e.g. https://yourapp.webflow.io)const WEBFLOW_UPSTREAM = 'https://yourapp.webflow.io';// 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(WEBFLOW_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();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);},};
Add the API key secret
Under Variables and Secrets, add a secret named LOVABLEHTML_API_KEY or run: wrangler secret put LOVABLEHTML_API_KEY
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.
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.
Verify the connection
Wait a couple of minutes for propagation. Hit your custom domain and confirm traffic reaches the Worker (and through it, Webflow).
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.
Test the integration
1. Send a crawler request
curl -sS -D - -o /dev/null \
-A "Googlebot" \
-H "Accept: text/html" \
"https://your-domain.com/"Look for a successful HTML response and the x-lovablehtml-render-cache header.
2. Confirm browser passthrough
curl -sS -D - -o /dev/null \
-A "Mozilla/5.0" \
-H "Accept: text/html" \
-H "Accept-Language: en-US" \
-H "Sec-Fetch-Mode: navigate" \
-H "Sec-Fetch-Dest: document" \
-H "Upgrade-Insecure-Requests: 1" \
"https://your-domain.com/"This request should come back from Webflow without an Encited render-cache header.
Common errors
The webflow.io upstream shows outdated content
Publish the site to the webflow.io staging domain in Webflow's publish dialog. The Worker forwards to that domain, so it must be included when you publish.
The Worker never receives traffic
Attach your domain to the Worker as a Custom Domain, not a Route. In this mode the Worker is the origin, so a Route has no proxied DNS record to intercept.
Crawler requests return errors instead of rendered HTML
Confirm the Worker has a secret named LOVABLEHTML_API_KEY with a valid API key from your Encited dashboard, then redeploy the Worker.
Request and security behavior
- Only crawler GET requests for public HTML pages are answered with pre-rendered snapshots.
- Along with the public page URL, only the API key and crawler-classification headers are sent to Encited.
- Cookie and Authorization headers are never forwarded to Encited.
- If Encited is unavailable or does not return rendered HTML, the request is forwarded to your Webflow project.
Common questions
Where do I find my Webflow project URL?
Open Site settings → Publishing → Staging and copy the webflow.io subdomain. That URL is the upstream the Worker forwards visitors to.
Will visitors notice the Worker?
No. Browsers are proxied to your published Webflow site and see the same pages on the same custom domain. Only crawlers get the pre-rendered HTML.
Is there a way to do this without Cloudflare?
Yes. The no-code DNS setup points two A records at Encited and needs no Cloudflare account or Worker.
