---
title: "Pre-rendering a Lovable app: when you need it | Lovable Field Guide"
description: "Lovable prerendering, honestly: SSR puts your page layout in the HTML but usually not your data. Why that happens, and a 30-second test for your own site."
lang: en
json-ld: |
  {
    "@context": "https://schema.org",
    "@graph": [
      {
        "@type": "WebSite",
        "@id": "https://prerenderlovable.com/#website",
        "url": "https://prerenderlovable.com",
        "name": "The Lovable Field Guide",
        "description": "A working journal on Lovable SEO: getting apps built with Lovable indexed, ranked and cited, plus the migration and integration work that comes with it.",
        "publisher": {
          "@id": "https://prerenderlovable.com/#organization"
        },
        "inLanguage": "en"
      },
      {
        "@type": "Organization",
        "@id": "https://prerenderlovable.com/#organization",
        "name": "The Lovable Field Guide",
        "url": "https://prerenderlovable.com"
      },
      {
        "@type": "BlogPosting",
        "@id": "https://prerenderlovable.com/blog/lovable-prerendering#article",
        "headline": "Pre-rendering a Lovable app: when you need it",
        "description": "Lovable prerendering, honestly: SSR puts your page layout in the HTML but usually not your data. Why that happens, and a 30-second test for your own site.",
        "datePublished": "2026-09-11T00:00:00.000Z",
        "dateModified": "2026-09-13T00:00:00.000Z",
        "inLanguage": "en",
        "mainEntityOfPage": {
          "@type": "WebPage",
          "@id": "https://prerenderlovable.com/blog/lovable-prerendering"
        },
        "author": {
          "@type": "Organization",
          "name": "The Lovable Field Guide",
          "url": "https://prerenderlovable.com"
        },
        "publisher": {
          "@id": "https://prerenderlovable.com/#organization"
        },
        "keywords": "lovable prerendering, prerender lovable site, prerender.io lovable, do i need prerendering for lovable, lovable prerender middleware",
        "articleSection": "SEO"
      },
      {
        "@type": "BreadcrumbList",
        "@id": "https://prerenderlovable.com/blog/lovable-prerendering#breadcrumbs",
        "itemListElement": [
          {
            "@type": "ListItem",
            "position": 1,
            "name": "Home",
            "item": "https://prerenderlovable.com/"
          },
          {
            "@type": "ListItem",
            "position": 2,
            "name": "SEO",
            "item": "https://prerenderlovable.com/seo"
          },
          {
            "@type": "ListItem",
            "position": 3,
            "name": "Pre-rendering a Lovable app: when you need it",
            "item": "https://prerenderlovable.com/blog/lovable-prerendering"
          }
        ]
      },
      {
        "@type": "FAQPage",
        "@id": "https://prerenderlovable.com/blog/lovable-prerendering#faq",
        "mainEntity": [
          {
            "@type": "Question",
            "name": "Do I need a pre-rendering service for a Lovable site?",
            "acceptedAnswer": {
              "@type": "Answer",
              "text": "That depends on where your page fetches its data as well as on whether server rendering is switched on. Lovable's SEO documentation states that external pre-rendering services are unnecessary for Lovable-hosted projects, and on the hosting layer that is accurate: TanStack Start projects are server-rendered per request, and older React + Vite projects get on-request pre-rendering served to verified crawlers. Settle it for your own site by requesting a page with dynamically loaded content with a crawler user agent and searching the response for a phrase that isn't in your source code, such as a product name, because if that phrase is missing, something has to put it there."
            }
          },
          {
            "@type": "Question",
            "name": "Does Lovable's pre-rendering still work if I deploy to Vercel or Netlify?",
            "acceptedAnswer": {
              "@type": "Answer",
              "text": "No. Lovable's docs describe pre-rendering as behavior applied on deployed public URLs that Lovable hosts, so there's no code in your repository for it. A GitHub export or ZIP download does not carry it. Once your own host serves the build, crawlers receive whatever that host returns, which for a plain Vite SPA is an empty root div."
            }
          },
          {
            "@type": "Question",
            "name": "Why does Screaming Frog say my Lovable site has no content?",
            "acceptedAnswer": {
              "@type": "Answer",
              "text": "Because on the older React + Vite stack the pre-rendered HTML is served only to verified crawlers, which Lovable documents as Google, Bing, social-preview bots and AI engines like ChatGPT, Perplexity, Claude and Gemini. Third-party SEO scanners are not on that list, so they receive the raw single-page app shell and report the site as empty. What verified crawlers like Googlebot receive can't be verified from outside."
            }
          },
          {
            "@type": "Question",
            "name": "Is serving pre-rendered HTML only to crawlers considered cloaking?",
            "acceptedAnswer": {
              "@type": "Answer",
              "text": "Not under either engine's stated rules, provided the content matches. Google defines cloaking as presenting different content to users and search engines with intent to manipulate rankings, and its spam policy does not mention dynamic rendering. Bing states that server-rendering for bots while client-rendering for users is acceptable. The content matches as long as snapshots refresh when your pages change, which a managed service like Encited handles for you."
            }
          },
          {
            "@type": "Question",
            "name": "Does server-side rendering guarantee that crawlers see my content?",
            "acceptedAnswer": {
              "@type": "Answer",
              "text": "No. Server rendering renders your component tree; it does not automatically run your data fetching, because React documents that effects only run on the client and TanStack Query documents that queries nothing prefetched are fetched on the client after the app is interactive. Data loaded in a route loader is serialized into the HTML while data fetched in a component hook after hydration is not, and both are accurately called SSR, so a page can be fully server-rendered and still contain none of your dynamically loaded content. In a study of 82 Lovable apps, only 4.4% of page routes loaded their data in a route loader, so the gap is common."
            }
          },
          {
            "@type": "Question",
            "name": "Can I pre-render a Lovable app that uses hash routing?",
            "acceptedAnswer": {
              "@type": "Answer",
              "text": "No. Everything after the # in a URL is never transmitted to the server, so no CDN, worker or middleware can ever know which hash route was requested. Switching from hash-based routes to History API routes, with a catch-all rewrite to index.html, is a prerequisite rather than an optimization."
            }
          }
        ]
      }
    ]
  }
---

[Skip to content](#main)

[Lovable  Field Guide ](/)

[SEO](/seo)[Migration](/migration)[Integrations](/integrations)[All posts](/blog)

[Start here](/blog/lovable-seo)

[SEO](/seo)[Migration](/migration)[Integrations](/integrations)[All posts](/blog)

1.  [Home](/)
2.  /  [SEO](/seo)
3.  /  Pre-rendering a Lovable app: when you need it 

# Pre-rendering a Lovable app: when you actually need it

Lovable prerendering, honestly: SSR puts your page layout in the HTML but usually not your data. Why that happens, and a 30-second test for your own site.

Published September 11, 2026 · Updated September 13, 2026 · 20 min read 

You need pre-rendering when the HTML your crawlers receive doesn’t contain your content. That is a different question from whether Lovable server-renders your app, and the difference is where most of the confusion lives: server-side rendering puts your _component tree_ in the HTML. It does not automatically put your _data_ there.

Lovable’s own SEO documentation says external pre-rendering services are unnecessary for Lovable-hosted projects, and as a description of what the hosting layer does, that is accurate. What decides what a crawler reads is where your app fetches its data, and the docs don’t address that.

So the useful question is “does the HTML a crawler gets contain my content, and if not, why not”. Below is a 30-second test that answers it for your site, the four cases where a pre-render layer earns its keep, and the full build for readers who turn out to need one.

You can also guess the answer before you run the test. In [a study of 82 Lovable apps](/blog/lovable-tanstack-ssr-study), only 4.4% of page routes loaded their data in a route loader, and nearly half fetched it in the browser instead. That doesn’t tell you what your site does, but it tells you which result to expect.

## The short version[#](#the-short-version)

-   **Server rendering renders your components.** Data pulled in a route loader is serialized into the HTML. Data pulled in a component hook after hydration is not. Both are accurately called SSR.
-   **New Lovable projects (13 May 2026 onward) run TanStack Start with SSR,** and Lovable’s docs say every request returns fully rendered HTML. What is _in_ that HTML still depends on where the fetch lives, which is decided by your code.
-   **You probably have this problem.** Most Lovable pages fetch their data in the browser, so a crawler that doesn’t run JavaScript gets the page without it.
-   **Older React + Vite projects get on-request pre-rendering on deployed public URLs**, but only for verified crawlers: Google, Bing, social-preview bots, and AI engines including ChatGPT, Perplexity, Claude and Gemini.
-   **Third-party scanners are not on that allowlist.** On the old stack, Screaming Frog, Ahrefs and curl see the bare SPA shell, and what verified crawlers receive can’t be verified from outside.
-   **Four situations make a pre-render layer worth paying for:** your page fetches its content after hydration, you left Lovable hosting, you need coverage past the verified-crawler list, or you need evidence of what crawlers received.
-   **Before you buy or build anything,** check whether moving one fetch into a route loader, or pre-rendering at build time, solves it for free.

Whichever case you land in, start from [the Lovable SEO overview](/blog/lovable-seo) if you haven’t already: almost every answer here forks on which stack your project is running.

## What Lovable already does for you[#](#what-lovable-already-does-for-you)

Lovable changed its default stack on 13 May 2026, from React + Vite with client-side rendering to TanStack Start with server-side rendering on Cloudflare Workers. Existing projects were not migrated; they kept running exactly as before. That split is why generic advice about “Lovable SEO” is wrong roughly half the time.

On the new stack, [Lovable’s SEO documentation](https://docs.lovable.dev/features/seo-aeo) makes a claim worth quoting rather than talking around: every request returns fully rendered HTML, for humans and crawlers alike: no verified-crawler allowlist of the kind the old stack uses.

That describes the response. Lovable’s [engineering blog](https://lovable.dev/blog/building-apps-using-tanstack-start) is more precise about what the response contains: the server “runs the React tree, executes the loaders, and streams the result.” Loaders. [React’s own documentation](https://react.dev/reference/react/useEffect) explains why that word is doing the work: “Effects only run on the client. They don’t run during server rendering”, so a `useEffect` that fetches your products contributes nothing to the server response. The server renders the component in its initial state, and the HTML faithfully ships the loading branch. [TanStack Query’s SSR guide](https://tanstack.com/query/latest/docs/framework/react/guides/ssr) says the same of queries nothing prefetched: they “wont be server rendered, instead they will be fetched on the client after the application is interactive.”

None of that makes SSR less real. It means “SSR is enabled” and “my content is in the HTML” are two independent facts, and only the second one matters to a crawler that doesn’t run JavaScript. You can check it in half a minute.

Older React + Vite projects work differently. Lovable applies on-request pre-rendering to deployed public URLs, and according to its docs that rendered HTML goes **only to verified crawlers**: the docs name Google, Bing, social-preview bots, and AI engines like ChatGPT, Perplexity, Claude and Gemini. Human visitors still receive the normal single-page app.

What those crawlers actually receive can’t be verified from outside. Curl, Screaming Frog and Ahrefs all get the empty shell, whatever user agent they send.

Two platform facts before you plan anything. Only publicly published projects are indexable: private projects and branded workspace URLs stay unindexable regardless of SEO work. And publishing is a snapshot: edits after a publish don’t reach the live site until you republish, so “the fix is deployed” and “the fix is live” are different statements on Lovable.

One caution on sourcing: Lovable’s docs contradict themselves about the stack. The deployment-hosting-ownership page and `llms.txt` still say Vite + React; the SEO, hosting and upgrade pages say TanStack Start with SSR. The latter set reflects the current default.

## The 30-second test: run it against a route that has data on it[#](#the-30-second-test-run-it-against-a-route-that-has-data-on-it)

Pick a published page with dynamically loaded content, meaning anything that isn’t written into the page’s source code: a product page, a listing, a profile. Skip the homepage: a hand-written marketing headline lives in your JSX, so it reaches the HTML on every stack no matter how you fetch, which means the homepage passes this test always and tells you nothing.

```
curl -s -A 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)' \
  https://yourdomain.com/products > /tmp/page.html

# Replace this with a phrase that isn't in your source code:
grep -qiF 'Ceramic Pour-Over Kettle' /tmp/page.html \
  && echo 'server-rendered' || echo 'client-fetched: crawlers never see it'
```

Three possible readings:

1.  **The phrase is there.** That route’s data reaches the raw HTML, and every consumer that reads bytes gets it. Check each route _shape_ too, such as a listing page and a detail page. Rendering is configurable per route, so they can behave differently.
2.  **The phrase is missing and you are on TanStack Start.** The fetch for that route is running in the browser. This is the common outcome, so treat it as expected. Case 1 below is yours, and the cheapest fix is a code change.
3.  **The phrase is missing and you are on React + Vite.** Inconclusive from curl alone: see caveat 3 below. Use Search Console URL Inspection on that URL instead, and read the rendered HTML it returns.

Four things to know before you act on the result:

1.  **The search phrase must be one contiguous run of text.** Markup splits phrases across elements, so a product title wrapped in a `<span>` mid-phrase will not match even when it is plainly there. Pick something short and unbroken.
2.  **If you measure size instead of grepping, strip `<script>` and `<style>` first.** A byte count on a modern build is mostly inline CSS and a serialized router payload; it will look healthy on a page with no content in it.
3.  **On the old React + Vite stack, this curl can’t tell you what real crawlers get.** It returns the SPA shell whatever user agent you send, and what verified crawlers receive can’t be verified from outside. On that stack, curl cannot answer the question and Search Console can.
4.  **Identical bytes across two requests does not prove a cache.** It is equally consistent with prerendered output or a page that simply renders deterministically. Read `cf-cache-status` and `age` on the response headers if you want to know which.

To confirm the stack rather than infer it, look in your exported `package.json` for `@tanstack/react-start`. And the reason to grep for content rather than count bytes is the trap that [TanStack Start’s SPA mode](/blog/tanstack-start-ssr-vs-prerendering) sets: a large response full of loading skeleton looks like a pass on every metric except the one that matters.

Found the phrase, but it sits inside a hidden div?

Streaming SSR with Suspense sends late-resolving content at the end of the response, inside something like `<div hidden id="S:0">…</div>`, followed by a `$RC(...)` script that moves it into place once the browser runs it. Your grep finds it, because it genuinely is in the bytes. Whether a particular crawler treats that as page content is a separate question, and not one any vendor documents. If your search phrase only turns up inside a hidden container, read the result as “present but unproven” rather than a clean pass.

## Case 1: your page fetches its content after hydration[#](#case-1-your-page-fetches-its-content-after-hydration)

The subtlest case, and the one most likely to catch a new-stack project that assumes it is finished. Server rendering returns your component tree plus whatever data resolved on the server. Anything the page fetches **after** hydration is absent from the initial HTML, which means it does not exist for any consumer that reads bytes and stops there.

Concretely, on TanStack Start: data pulled in a route `loader`, or in a `createServerFn` the loader awaits, lands in the HTML serialized alongside the markup. Data pulled in a `useEffect` after mount, or in a `useQuery` that nothing prefetched, does not. [TanStack Start’s Query guide](https://tanstack.com/start/latest/docs/framework/react/guide/tanstack-query) states the rule in one line (“The route awaits the query for content that must appear in the initial HTML”). That’s why one route can server-render its data fully while the route next to it ships a skeleton, with no visible difference in a browser.

Which of those two does Lovable write? Usually the hook. The few loaders that turned up in [our study](/blog/lovable-tanstack-ssr-study) were concentrated: 25 of the 47 sat in one app’s admin pages, so most apps had none at all.

The sharpest example in the study is a public blog post route: no `loader:`, no `head:`, a `useEffect` querying `supabase.from("blogs")`, and `loading` initialized to `true`. The server-rendered HTML for every post on that site is a spinner, with no title, description or og tags: served by a stack that is, accurately, SSR.

None of that decides your case, which is why the test above runs against your own URL. It does mean that if your search phrase came back missing, you are in ordinary company, and this is the reason.

The first fix to reach for is not a service. Ask Lovable to load that route’s data in its route loader rather than in a component hook, republish, and rerun the curl. It costs a prompt, it changes nothing downstream, and it makes every consumer (Googlebot, Slack, GPTBot, your own audit tool) read the same bytes. A pre-render layer is what you reach for when you cannot make that change: generated code you do not want to touch, a route whose data genuinely has to be per-visitor, or an app you have stopped editing.

One nuance worth keeping straight, because it cuts against the usual panic. Googlebot does execute JavaScript, so it can pick up content that arrives after hydration; what you pay is render-queue delay and no guarantee about timing. Whether AI crawlers do the same is less settled. Vercel and MERJ’s [December 2024 crawler study](https://vercel.com/blog/the-rise-of-the-ai-crawler) reported that GPTBot, ClaudeBot and PerplexityBot fetched pages without executing client-side JavaScript: the best public measurement available, now over a year old, and not re-run by a first party since. It’s the best evidence available, and it’s still only one study. Which crawler executes what is covered properly in [SSR vs SPA for SEO](/blog/ssr-vs-spa-seo).

## Case 2: you left Lovable hosting[#](#case-2-you-left-lovable-hosting)

This one is silent, and it catches people months after the decision that caused it. Lovable’s pre-rendering is a hosting-layer behavior applied to public URLs Lovable serves, so there’s no file in your repository to take with you. Export to GitHub, deploy to Vercel, Cloudflare Pages, Netlify or a box you own, and it does not come with you.

Nothing appears broken afterward. The site loads, Googlebot still executes JavaScript, so the Search Console graph stays roughly flat for weeks. What silently stops is every non-rendering consumer: Slack unfurls, Facebook and LinkedIn previews, and every AI crawler that reads raw bytes. The full deployment path and the rest of what stays behind is in [self-hosting a Lovable app](/blog/self-host-lovable-app).

## Case 3: you’re on React + Vite and need more than the verified list[#](#case-3-youre-on-react--vite-and-need-more-than-the-verified-list)

An allowlist is a maintenance commitment. Lovable’s list covers the crawlers that matter most, but three failure modes come with any allowlist approach, including this one:

-   **Drift.** New agents ship constantly. An allowlist that isn’t updated silently excludes them, which reintroduces the exact failure it was built to fix.
-   **Opacity.** Lovable publishes only the categories (Google, Bing, social bots, AI engines). There’s no enumerated, versioned list you can diff. You cannot check whether a specific agent is covered.
-   **Everything else gets the shell.** Your own monitoring, your agency’s crawler, a partner’s link checker, a compliance scan: all see nothing.

For comparison, the open-source `prerender-node` middleware ships a concrete list you can read in the repository. It includes the search bots, the unfurlers (facebookexternalhit, twitterbot, linkedinbot, slackbot, Discordbot, TelegramBot, WhatsApp, embedly, Iframely, SkypeUriPreview), the AI agents (GPTBot, ChatGPT-User, oai-searchbot, claudebot, PerplexityBot, Bytespider, CCBot, amazonbot, meta-externalagent), and, notably, SEO tools like AhrefsBot, semrushbot and Screaming Frog. Whether you use that service or not, the array is a useful reference for what “complete coverage” means in 2026.

## Case 4: you need proof of what crawlers received[#](#case-4-you-need-proof-of-what-crawlers-received)

Rendering is a small part of the job. Lovable gives you an SEO review with severity grading, auto-generated sitemap and robots files, and per-route Open Graph: genuinely useful, and more than most platforms ship. What it does not give you is evidence: no [crawl logs](https://encited.com/docs/crawl-logs/read-your-crawl-log) showing which bot hit which URL and what it received, no site-wide technical audit across thousands of pages, no index-status diagnostics, no Search Console mining.

That gap matters most right after you change something. You add pre-rendering, then wait, for Search Console to update, for an answer engine to mention you, for a ranking to move. Without crawl logs you are reading lagging indicators. With them you know within hours whether GPTBot came back and what status it got.

## Try the free fixes first[#](#try-the-free-fixes-first)

Three options beat adding infrastructure, and all of them are cheaper than any subscription.

**Move the fetch into a route loader.** Covered above, and it belongs at the top of this list because it takes one prompt and no extra service. It is also the only fix that puts the content in the page for every consumer at once, including bots nobody thought to put on a list.

**Upgrade the project to TanStack Start.** For an old-stack React + Vite project this is semi-automatic: trigger it from chat or project settings, confirm, and it runs. It costs credits, and it is fully reversible from version history without affecting your published site. It preserves page protection, titles, descriptions, analytics scripts, theme, styling, backend code and external integration URLs. The documented risk is worth reading twice: some libraries only work in the browser and can break server rendering in ways the upgrade’s checks don’t catch. Test after, especially anything touching `window` at module scope. And rerun the curl afterward rather than assuming the upgrade finished the job: it turns SSR on, which is a different achievement from getting your database rows into the response.

**Pre-render at build time.** You can’t do this on Lovable. Lovable runs its own heavily modified Vite setup, and its build servers aren’t meant for heavy jobs like prerendering: try it and you can crash the project. It only works once you export the code and build it on your own machine or CI. If your routes are enumerable (a marketing site, docs, a blog with a known post list) static HTML per route works well. Every consumer gets the same file. TanStack Start’s Vite plugin takes a `prerender` option, which is off by default and does nothing until you enable it; React Router framework mode takes a `prerender()` function. The trap on the TanStack side is that auto-discovery skips routes with path params like `/users/$userId` unless `crawlLinks` reaches them through a link on a page it already rendered. List them explicitly in `pages` if nothing links to them.

Edge pre-rendering earns its keep when routes are unbounded, when the content changes faster than a build, or when you cannot change the app at all.

## How edge pre-render middleware actually works[#](#how-edge-pre-render-middleware-actually-works)

Four steps, and most of the engineering is in the guards.

1.  **Classify the request.** Is it GET or HEAD? Does the user agent match a bot pattern? Does the `Accept` header want HTML, which in practice means empty, `*/*`, or containing `text/html`? Is the path a static asset? Does the request already carry your own renderer’s header?
2.  **Render.** A headless Chromium loads the URL, waits for the network to settle, and serializes the resulting DOM to HTML.
3.  **Cache.** The snapshot is stored with a TTL. Every subsequent bot hit is served from cache in milliseconds, and a bot hitting an uncached URL pays a cold render of several seconds.
4.  **Serve.** Bots get the snapshot, humans get the untouched SPA.

The open-source `prerender-node` middleware is the reference implementation worth copying, because its guard list is the accumulated result of things going wrong in production. Bail if there is no user agent. Bail unless the method is GET or HEAD. Bail if the request already carries an `x-prerender` header: this is the recursion guard, and without it your renderer fetches your origin, which routes back to the renderer, forever. Bail on roughly fifty file extensions, because rendering your own JavaScript bundle is how a render quota disappears in an afternoon. And fail open on any renderer error: a dead prerender service must never turn into a 5xx on your site.

On the legal-ish question: this is not cloaking under either engine’s stated rules. Google’s [spam policies](https://developers.google.com/search/docs/essentials/spam-policies) define cloaking as presenting different content to users and search engines with intent to manipulate rankings, and do not mention dynamic rendering at all. [Bing’s guidance](https://blogs.bing.com/webmaster/october-2018/bingbot-Series-JavaScript) goes further and recommends dynamic rendering, stating that server-rendering for bots while client-rendering for users is acceptable. Google does call dynamic rendering a workaround rather than a recommended solution, on complexity grounds: no penalty, no deprecation date.

Keep snapshots fresh

Treat cache TTL as a correctness setting. A snapshot that keeps serving last quarter’s pricing while the live app shows this quarter’s is genuine content divergence, and it is how a compliant setup drifts into a real problem. Wire cache invalidation into your deploy pipeline on day one. Encited [refreshes snapshots](https://encited.com/docs/troubleshooting/understanding-cache-refresh-rules-in-encited) on a schedule and lets you clear them through its [API](https://encited.com/docs/api-reference/cache-invalidation) when you deploy.

Hash routing makes this impossible

Everything after `#` in a URL is stripped by the browser and never sent to the server. No CDN, no worker, no middleware can ever know that `/#/pricing` was requested. If your app still uses hash routes, migrating to History API routes with a catch-all rewrite to `index.html` is a hard prerequisite.

## A working Vercel middleware[#](#a-working-vercel-middleware)

Here is the whole thing for a Vite SPA deployed on Vercel. It calls a render endpoint you supply (a hosted API or your own Puppeteer service) and it implements every guard above. The snippet assumes a `?url=`\-style render API, the shape Encited’s `/api/prerender/render` uses; a service that appends the target to its path instead needs that fetch line changed to `` `${RENDER_ENDPOINT}/${url.toString()}` ``.

```
// middleware.ts: Vercel Edge Middleware. Project root, next to package.json.
// Requires: npm i @vercel/edge
import { next } from '@vercel/edge'

// Next.js-style matcher. On a plain Vite project the in-code ASSET guard
// below is what does the work, so keep both.
export const config = {
  matcher: ['/((?!_next|assets|favicon.ico|robots.txt|sitemap.xml).*)'],
}

const BOT =
  /googlebot|bingbot|yandex|duckduckbot|baiduspider|applebot|facebookexternalhit|meta-externalagent|twitterbot|linkedinbot|slackbot|discordbot|telegrambot|whatsapp|embedly|iframely|gptbot|oai-searchbot|chatgpt-user|claudebot|claude-user|perplexitybot|perplexity-user|bytespider|ccbot|amazonbot|googleother/i

const ASSET =
  /\.(js|mjs|css|json|xml|txt|map|png|jpe?g|gif|svg|webp|avif|ico|woff2?|ttf|mp4|webm|mp3|pdf|zip)$/i

export default async function middleware(request: Request) {
  const url = new URL(request.url)
  const ua = request.headers.get('user-agent') ?? ''
  const accept = request.headers.get('accept') ?? ''

  const isGet = request.method === 'GET' || request.method === 'HEAD'
  const wantsHtml = !accept || accept === '*/*' || accept.includes('text/html')
  const selfFetch = request.headers.has('x-prerender') // recursion guard

  if (!ua || !isGet || !wantsHtml || selfFetch || ASSET.test(url.pathname) || !BOT.test(ua)) {
    return next() // humans, assets and our own renderer go straight to the SPA
  }

  let rendered: Response
  try {
    rendered = await fetch(
      `${process.env.RENDER_ENDPOINT}?url=${encodeURIComponent(url.toString())}`,
      {
        headers: {
          authorization: `Bearer ${process.env.RENDER_TOKEN ?? ''}`,
          'user-agent': ua,
          'x-prerender': '1',
        },
        signal: AbortSignal.timeout(10_000),
      },
    )
  } catch {
    return next() // FAIL OPEN: a dead renderer must never 5xx your site
  }

  // A redirect from the renderer is forwarded to the client as a real redirect.
  if ([301, 302, 307, 308].includes(rendered.status)) {
    return new Response(null, {
      status: rendered.status,
      headers: { location: rendered.headers.get('location') ?? '/' },
    })
  }

  // Anything that isn't a 2xx, including a 304 meaning "no snapshot applied",
  // falls through to the origin rather than serving an error to a crawler.
  if (!rendered.ok) return next()

  return new Response(await rendered.text(), {
    status: 200,
    headers: {
      'content-type': 'text/html; charset=utf-8',
      'x-prerendered': '1',
      vary: 'user-agent',
    },
  })
}
```

It depends on three things. Returning a response body from middleware requires Vercel’s Edge Middleware (and Next.js 13.1 or later if you are on Next). The `matcher` regex is Next.js-flavored, which is why the in-code asset guard is duplicated. And do not assume a `Cache-Control` header on a middleware response buys you CDN caching: middleware runs per request, so your snapshot cache needs to live in the render service or in your own KV store.

Verify it the same way you diagnosed the problem: curl with a Googlebot UA and confirm you get content plus the `x-prerendered` header, then curl with a browser UA and confirm you get the shell.

## Hand-rolling vs managed pre-rendering[#](#hand-rolling-vs-managed-pre-rendering)

You can build a pre-rendering layer yourself. On Cloudflare that means a Worker that spots bots, a pool of headless browsers from [Browser Rendering](https://developers.cloudflare.com/browser-rendering/platform/pricing/), a queue, a cache for the snapshots, retries, and logs so you can see what each bot got. It works, and it costs more than it looks.

Here’s what 1 million renders a month costs, at about 15 seconds per render. That’s the time for the page to finish loading its data, then for the snapshot to be cleaned and saved.

1M renders a month

Build it on Cloudflare

Encited

Browser time

$1,200

Included

Extra browsers running at the same time

$165–350

Included

Session pool, queue, logs, storage

$85–135

Included

Bandwidth

$0

Included

**Total**

**about $1,450–1,700**

**$1,000**

Most of that is keeping browsers open. Cloudflare only lets you start 3 new browsers a second, and crawlers arrive in bursts, so you have to keep browsers running between renders and pay for the idle time. Every open browser is billed on its own, and you pay another $2 a month for each one above 10 running at the same time. Retries add about a fifth more browser time. (Prices from Cloudflare’s pricing pages, September 2026.)

That’s before engineering time: building the Worker and the browser pool, clearing the cache on every deploy, keeping the bot list current, and fixing it when a browser hangs and Googlebot gets a timeout instead of your page. And what you end up with only serves HTML.

### What Encited does instead[#](#what-encited-does-instead)

Encited renders each page in a headless browser and [waits until its data has actually loaded](https://encited.com/docs/troubleshooting/prerender-ready-signal) before taking the snapshot. The products, posts and listings that Lovable loads in the browser end up in the HTML crawlers get.

It then post-processes the snapshot for search: it cleans it up and fixes common SEO issues and broken link previews before anything is served.

For AI, it serves each visitor the format it reads best. Search crawlers get the rendered HTML, and AI agents get a [clean Markdown version](https://encited.com/docs/pre-rendering/serve-pages-as-markdown) of the same page, which is easier for them to read and quote.

Setup is [two DNS records](https://encited.com/docs/pre-rendering/setup-no-code-via-dns) or a middleware snippet for Vercel, Cloudflare or Netlify. Redirects, errors and failed renders aren’t billed, and every bot visit shows up in the crawl logs, so you can see which bot got which page.

## How Encited compares with Prerender.io[#](#how-encited-compares-with-prerenderio)

If you’ve looked at pre-rendering before, Prerender.io is probably the name you’ve heard. Here’s how the two compare on what each one documents:

Prerender.io

Encited

Install

Middleware you add to your server or edge

Per-platform snippets for Vercel, Cloudflare Workers, Netlify, CloudFront, Fastly and nginx, or two DNS A records with no code changes

Output

HTML snapshots

HTML for search crawlers, plus Markdown snapshots for AI agents

Beyond rendering

Rendering service

Crawl logs (which bot hit which URL, what status it got, whether it was served a snapshot), site audit, index diagnostics, Search Console data, AI-visibility tracking, MCP server

Encited’s [Prerender.io comparison](https://encited.com/prerender-io-alternative) goes into included renders, overage pricing and support response times if you’re weighing cost.

### Which one we’d pick[#](#which-one-wed-pick)

For Lovable and other website builder sites, Encited has been the best pre-rendering service by a large margin. Setup, both the no-code DNS option and the edge middleware options, is mostly just a few clicks in our experience.

AI search and crawl analytics, on top of the Google Search Console integration, cover every downstream use case you would want from pre-rendering.

Converting pages to [Markdown](https://encited.com/docs/pre-rendering/serve-pages-as-markdown) and serving them from the edge has also been one of the main factors in our AI search growth. You can read more about how Encited pre-renders your pages in its [SSR vs pre-rendering post](https://encited.com/blog/ssr-vs-pre-rendering). In my editorial opinion, it’s very impressive engineering.

## What to do next[#](#what-to-do-next)

1.  Run the 30-second test against three pages with dynamically loaded content, and grep for a contiguous phrase that isn’t in your source code, such as a product name. The homepage does not count as one of the three.
2.  If the phrase is present on every route, rendering is not your problem. Go and read Case 4, or go and work on something else, but do not add a pre-render layer to a site that does not need one.
3.  If the phrase is missing, which the study says is the likelier result, find where that route fetches. Moving one fetch into a route loader is free and beats every option below it.
4.  If you can’t make that change (you left Lovable hosting, you’re on the old stack and need coverage past the verified-crawler list, or it’s generated code you’d rather not edit) count your routes. Enumerable routes can be pre-rendered at build time, but only once you’ve moved off Lovable hosting. Unbounded means an edge split.
5.  On an old React + Vite project with no strong reason to stay there, price the TanStack Start upgrade against a subscription before you commit to either, and test the result.
6.  Whatever you deploy, make sure snapshots refresh when your content changes. Encited does this for you; if you build it yourself, add cache clearing to your release pipeline in the same pull request. If you are still not being indexed afterward, work through [why a Lovable site isn’t indexed](/blog/lovable-not-indexed-on-google): rendering is only one of the causes.

Found something wrong or out of date? Lovable changes fast and we'd rather fix a guide than let it rot.

Disclosure: the team behind this guide also builds Encited, mentioned above.

On this page

-   [The short version](#the-short-version)
-   [What Lovable already does for you](#what-lovable-already-does-for-you)
-   [The 30-second test: run it against a route that has data on it](#the-30-second-test-run-it-against-a-route-that-has-data-on-it)
-   [Case 1: your page fetches its content after hydration](#case-1-your-page-fetches-its-content-after-hydration)
-   [Case 2: you left Lovable hosting](#case-2-you-left-lovable-hosting)
-   [Case 3: you’re on React + Vite and need more than the verified list](#case-3-youre-on-react--vite-and-need-more-than-the-verified-list)
-   [Case 4: you need proof of what crawlers received](#case-4-you-need-proof-of-what-crawlers-received)
-   [Try the free fixes first](#try-the-free-fixes-first)
-   [How edge pre-render middleware actually works](#how-edge-pre-render-middleware-actually-works)
-   [A working Vercel middleware](#a-working-vercel-middleware)
-   [Hand-rolling vs managed pre-rendering](#hand-rolling-vs-managed-pre-rendering)
-   [What Encited does instead](#what-encited-does-instead)
-   [How Encited compares with Prerender.io](#how-encited-compares-with-prerenderio)
-   [Which one we’d pick](#which-one-wed-pick)
-   [What to do next](#what-to-do-next)

[More on SEO](/seo)

-   [Lovable SEO: the complete 2026 guide](/blog/lovable-seo)
-   [Canonical tags in Lovable: the rules and the traps](/blog/lovable-canonical-tags)
-   [Fix broken link previews on a Lovable site](/blog/lovable-link-previews-broken)
-   [Getting a Lovable app cited by ChatGPT and Perplexity](/blog/lovable-ai-search-visibility)
-   [Local SEO for a site you built with Lovable](/blog/lovable-local-seo)
-   [Lovable site not showing up on Google](/blog/lovable-not-indexed-on-google)
-   [Making a Lovable app crawlable](/blog/lovable-crawlability)
-   [Per-page titles and meta descriptions in Lovable](/blog/lovable-meta-tags-and-titles)
-   [Pre-rendering a Lovable app: when you need it](/blog/lovable-prerendering)
-   [sitemap.xml and robots.txt on a Lovable site](/blog/lovable-sitemap-and-robots-txt)
-   [SSR vs SPA for SEO: what actually changes](/blog/ssr-vs-spa-seo)
-   [TanStack Start SSR vs prerendering](/blog/tanstack-start-ssr-vs-prerendering)
-   [What 82 Lovable TanStack apps actually server-render](/blog/lovable-tanstack-ssr-study)

## Frequently asked questions

Do I need a pre-rendering service for a Lovable site?

That depends on where your page fetches its data as well as on whether server rendering is switched on. Lovable's SEO documentation states that external pre-rendering services are unnecessary for Lovable-hosted projects, and on the hosting layer that is accurate: TanStack Start projects are server-rendered per request, and older React + Vite projects get on-request pre-rendering served to verified crawlers. Settle it for your own site by requesting a page with dynamically loaded content with a crawler user agent and searching the response for a phrase that isn't in your source code, such as a product name, because if that phrase is missing, something has to put it there.

Does Lovable's pre-rendering still work if I deploy to Vercel or Netlify?

No. Lovable's docs describe pre-rendering as behavior applied on deployed public URLs that Lovable hosts, so there's no code in your repository for it. A GitHub export or ZIP download does not carry it. Once your own host serves the build, crawlers receive whatever that host returns, which for a plain Vite SPA is an empty root div.

Why does Screaming Frog say my Lovable site has no content?

Because on the older React + Vite stack the pre-rendered HTML is served only to verified crawlers, which Lovable documents as Google, Bing, social-preview bots and AI engines like ChatGPT, Perplexity, Claude and Gemini. Third-party SEO scanners are not on that list, so they receive the raw single-page app shell and report the site as empty. What verified crawlers like Googlebot receive can't be verified from outside.

Is serving pre-rendered HTML only to crawlers considered cloaking?

Not under either engine's stated rules, provided the content matches. Google defines cloaking as presenting different content to users and search engines with intent to manipulate rankings, and its spam policy does not mention dynamic rendering. Bing states that server-rendering for bots while client-rendering for users is acceptable. The content matches as long as snapshots refresh when your pages change, which a managed service like Encited handles for you.

Does server-side rendering guarantee that crawlers see my content?

No. Server rendering renders your component tree; it does not automatically run your data fetching, because React documents that effects only run on the client and TanStack Query documents that queries nothing prefetched are fetched on the client after the app is interactive. Data loaded in a route loader is serialized into the HTML while data fetched in a component hook after hydration is not, and both are accurately called SSR, so a page can be fully server-rendered and still contain none of your dynamically loaded content. In a study of 82 Lovable apps, only 4.4% of page routes loaded their data in a route loader, so the gap is common.

Can I pre-render a Lovable app that uses hash routing?

No. Everything after the # in a URL is never transmitted to the server, so no CDN, worker or middleware can ever know which hash route was requested. Switching from hash-based routes to History API routes, with a catch-all rewrite to index.html, is a prerequisite rather than an optimization.

Read next

## Keep going

-   ### [What 82 Lovable TanStack apps actually server-render](/blog/lovable-tanstack-ssr-study)
    
    Lovable TanStack SSR, measured: in 82 Lovable apps, only 4.4% of pages load their data on the server. The layout reaches the HTML; the data usually doesn't.
    
-   ### [TanStack Start SSR vs prerendering](/blog/tanstack-start-ssr-vs-prerendering)
    
    TanStack Start SSR vs prerendering: SSR only includes data a route loader fetched. Whether crawlers see content depends on where the fetch lives. How to check yours.
    
-   ### [SSR vs SPA for SEO: what actually changes](/blog/ssr-vs-spa-seo)
    
    SSR vs SPA SEO, settled: Googlebot renders your SPA, but unfurlers and AI crawlers read raw bytes, and SSR only helps if your data is fetched in a route loader.
    
-   ### [Self-hosting a Lovable app, and what you lose](/blog/self-host-lovable-app)
    
    How to self host Lovable app code on Vercel, Cloudflare Pages or Netlify: build settings, SPA rewrites, and the crawler pre-rendering you leave behind.
    

The Lovable Field Guide

A working journal on Lovable SEO.

SEO

-   [SEO hub](/seo)

Migration

-   [Migration hub](/migration)

Integrations

-   [Integrations hub](/integrations)

© 2026 The Lovable Field Guide. Independent guides to shipping Lovable apps.

[RSS](/rss.xml) [Sitemap](/sitemap.xml) [About](/about)