How it works (SPAs)
Your Lambda@Edge function calls /api/prerender/render?url=... for HTML document requests. If Encited returns 200 you serve rendered HTML. If it returns 304 with a Location header, prerendering didn't apply — fall back to your origin (return the original request).
CloudFront Functions can't make network calls, so they can't reach the prerender service. Use Lambda@Edge on the origin-request event instead — it has a global fetch and a 1 MB generated-response body limit, which is enough for HTML.
Prerequisites
- A Encited account and API key (Settings → API Keys)
- A domain added + verified in your Encited dashboard
- A CloudFront distribution serving your site (S3, a custom origin, or your SPA host)
Setup
Replace <your-api-key> in the snippet. Lambda@Edge has no environment variables, so the key is inlined into the source — keep the function private and rotate the key if it leaks.
Create a Lambda function in us-east-1
Lambda@Edge functions must live in us-east-1 (N. Virginia), regardless of where your distribution serves traffic. Use the Node.js 18+ runtime and name the file index.mjs so it's treated as an ES module (the snippet uses export const handler). Region restriction docs
Add the edge trust policy
Lambda@Edge runs under a different principal than normal Lambda. The execution role's trust policy must allow both services:
Trust both lambda and edgelambda
Add lambda.amazonaws.com and edgelambda.amazonaws.com to the role's trusted entities, or CloudFront won't be able to replicate the function to edge locations.
Edit the config and raise the timeout
There are no environment variables at the edge, so config is inlined. Set PUBLIC_HOST to yourdomain.com and paste your API key where the snippet expects it. Then set the function timeout to 30s — the maximum allowed on an origin-request trigger.
Publish a version and attach it to CloudFront
CloudFront can only reference a published, numbered version — not $LATEST and not an alias. Publish a new version, then add it to your cache behavior as an Origin request trigger. Every code change means a new version and re-associating it.
Forward viewer headers to the function
At origin-request, CloudFront strips viewer headers unless your policy forwards them, and replaces User-Agent with Amazon CloudFront unless forwarded — which would break crawler detection.
Use the AllViewerExceptHostHeader policy
Attach the managed AllViewerExceptHostHeader origin request policy. Forwarding the Host header to an S3 origin breaks it — which is why the snippet reconstructs the public URL from PUBLIC_HOST instead of the request's Host.
// index.mjs (AWS Lambda@Edge — attach to an "Origin request" trigger)//// Why Lambda@Edge and not a CloudFront Function?// CloudFront Functions cannot make network calls, so they can't reach the// prerender service. Lambda@Edge (Node.js 18+) has a global fetch and a 1 MB// generated-response body limit on the origin-request event — enough for HTML.//// Deploy notes:// 1. Create the function in us-east-1 (Lambda@Edge requirement), then deploy// it to your CloudFront distribution as an "Origin request" trigger.// 2. This is an ES module — the file must be named index.mjs (or set// "type": "module" in package.json). Use 'export const handler', NOT// 'exports.handler', or you'll get "exports is not defined in ES module scope".// 3. Lambda@Edge does NOT support environment variables — the API key is// inlined below.// 4. In the cache behavior, forward (and include in the cache key) the// User-Agent, Accept, and Accept-Language headers so crawler vs. browser// requests are routed and cached separately.// CHANGE THIS: your canonical public domain. On an origin-request trigger// CloudFront rewrites the Host header to the origin's hostname, so we can't// derive the viewer's domain from the request — the prerender service must be// queried with your real public URL or it won't find the page.const PUBLIC_HOST = 'yourdomain.com';export const handler = async (event) => {const request = event.Records[0].cf.request;const header = (name) => {const h = request.headers[name.toLowerCase()];return h && h[0] ? h[0].value : '';};// 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 = (header('accept') || '').trim();const isHtmlRequest = !accept || accept === '*/*' || accept.includes('text/html');if (request.method !== 'GET' || !isHtmlRequest) return request;// Reconstruct the full public URL. Use PUBLIC_HOST, not the (origin-rewritten)// Host header.const fullUrl ='https://' + PUBLIC_HOST + request.uri + (request.querystring ? '?' + request.querystring : '');try {const headers = {'x-lovablehtml-api-key': <your-api-key>,accept: 'text/html','accept-language': header('accept-language'),'sec-fetch-mode': header('sec-fetch-mode'),'sec-fetch-site': header('sec-fetch-site'),'sec-fetch-dest': header('sec-fetch-dest'),'sec-fetch-user': header('sec-fetch-user'),'upgrade-insecure-requests': header('upgrade-insecure-requests'),referer: header('referer'),'user-agent': header('user-agent'),};const r = await fetch('https://encited.com/api/prerender/render?url=' + encodeURIComponent(fullUrl), { headers });// 301 = configured redirect rule matched — forward to clientif (r.status === 301) {const loc = r.headers.get('location');if (loc) {return {status: '301',statusDescription: 'Moved Permanently',headers: {location: [{ key: 'Location', value: loc }],'cache-control': [{ key: 'Cache-Control', value: 'no-store' }],},};}}// 304 = not pre-rendered, pass through to originif (r.status === 304) return request;if ((r.headers.get('content-type') || '').includes('text/html')) {return {status: '200',statusDescription: 'OK',headers: {'content-type': [{ key: 'Content-Type', value: 'text/html; charset=utf-8' }],},body: await r.text(),};}} catch {// Prerender unreachable → fall through so visitors still get the site}// Safety fallback: never block the requestreturn request;};
Caveats specific to Lambda@Edge
These are the things that trip people up on CloudFront. Read them before you deploy.
Deployment & limits
The function must live in us-east-1. Lambda@Edge functions are authored only in N. Virginia, regardless of where your distribution serves traffic. Create it there or CloudFront can't attach it. See the region restriction docs.
- CloudFront references a published, numbered version — never
$LATESTand never an alias. Every code change means publishing a new version and re-associating it with the distribution. - Trust policy needs two principals. The execution role must trust both
lambda.amazonaws.comandedgelambda.amazonaws.com, or replication to edge locations fails. See the Lambda@Edge permissions docs. - No environment variables at the edge. Config (your API key, your public host) is inlined/bundled into the source. This is why the snippet hard-codes the key — see the security note below.
- Set the function timeout to 30s — the maximum allowed on an origin-request trigger. See the function restrictions docs.
- Use an ES module. Name the file
index.mjs(or set"type": "module") and export withexport const handler, notexports.handler, or you'll get "exports is not defined in ES module scope".
Header forwarding & URL reconstruction
At the origin-request event, CloudFront rewrites the Host header to your origin's hostname (e.g. your S3 bucket), not your public domain. Reconstruct the public URL from a constant (PUBLIC_HOST in the snippet) — never from the Host header — or the prerender service won't find the page.
- Viewer headers are stripped at origin-request unless they're in the cache policy or an origin request policy. In particular,
User-Agentis replaced withAmazon CloudFrontunless you forward it — which would break crawler detection. - Attach the managed
AllViewerExceptHostHeaderorigin request policy. Forwarding theHostheader to an S3 origin breaks the S3 request, which is exactly whyAllViewerExceptHostHeader(notAllViewer) is the right choice.
S3 origins & SPA routing
- If your origin is a private S3 bucket, missing keys return
403, not404. Your SPA routing / error-document configuration must account for that when deciding which paths fall through toindex.html.
Security
- Because there are no edge environment variables, your API key lives in the function source. Restrict who can read/update the function, and rotate the key from Settings → API Keys if it's ever exposed.
How to Test
- Call the render API directly and verify
x-lovablehtml-render-cache: hit | miss - Hit your site with
Accept: text/htmland verify you get HTML back - Verify static assets (JS/CSS/images/fonts) are not proxied to Encited
# 1) Call the Encited render API directlycurl -sS -D - -o /dev/null \-H "x-lovablehtml-api-key: <API_KEY>" \-H "Accept: text/html" \"https://encited.com/api/prerender/render?url=https%3A%2F%2Fyour-domain.com%2Fyour-page"# Look for:# - HTTP/1.1 200# - x-lovablehtml-render-cache: hit | miss# - x-lovablehtml-snapshot-key: ...
# 2) Hit your site with an HTML Accept headercurl -sS -D - -o /dev/null \-H "Accept: text/html" \-A "Googlebot" \"https://your-domain.com/your-page"# Look for:# - HTTP/1.1 200# - content-type: text/html
# 3) Passthrough (no Accept: text/html → function should not call Encited)curl -sS -D - -o /dev/null \-A "Mozilla/5.0" \"https://your-domain.com/your-page"
Changes take a few minutes to propagate to edge locations after you publish a new version and re-associate it. If a test still hits the old behaviour, wait for replication to finish (CloudFront shows the distribution as "Deploying").
Best Practices
- Keep the function private — the API key is in the source. Limit IAM access and rotate the key if it leaks.
- Don't proxy static assets — only call Encited for HTML document requests. Always pass through JS/CSS/images/fonts.
- Handle 304 passthrough — 304 means prerendering doesn't apply. Return the original
requestso it falls through to your origin. - Re-publish & re-associate on every change — there's no
$LATESTat the edge; a numbered version is the only thing CloudFront will run. - Invalidate after content changes — use the cache invalidation endpoints (optionally with prewarm) after deploys or CMS updates.
Need help? Check the full API reference for prerender, cache, and analytics endpoint docs, or jump directly to Analytics API, or contact us if you run into issues.
