---
title: "Self-hosting a Lovable app, and what you lose | Lovable Field Guide"
description: "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."
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/self-host-lovable-app#article",
        "headline": "Self-hosting a Lovable app, and what you lose",
        "description": "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.",
        "datePublished": "2026-09-11T00:00:00.000Z",
        "dateModified": "2026-09-13T00:00:00.000Z",
        "inLanguage": "en",
        "mainEntityOfPage": {
          "@type": "WebPage",
          "@id": "https://prerenderlovable.com/blog/self-host-lovable-app"
        },
        "author": {
          "@type": "Organization",
          "name": "The Lovable Field Guide",
          "url": "https://prerenderlovable.com"
        },
        "publisher": {
          "@id": "https://prerenderlovable.com/#organization"
        },
        "keywords": "self host lovable app, deploy lovable app to vercel, lovable netlify deploy, lovable cloudflare pages, lovable prerendering self hosted",
        "articleSection": "Migration"
      },
      {
        "@type": "BreadcrumbList",
        "@id": "https://prerenderlovable.com/blog/self-host-lovable-app#breadcrumbs",
        "itemListElement": [
          {
            "@type": "ListItem",
            "position": 1,
            "name": "Home",
            "item": "https://prerenderlovable.com/"
          },
          {
            "@type": "ListItem",
            "position": 2,
            "name": "Migration",
            "item": "https://prerenderlovable.com/migration"
          },
          {
            "@type": "ListItem",
            "position": 3,
            "name": "Self-hosting a Lovable app, and what you lose",
            "item": "https://prerenderlovable.com/blog/self-host-lovable-app"
          }
        ]
      },
      {
        "@type": "FAQPage",
        "@id": "https://prerenderlovable.com/blog/self-host-lovable-app#faq",
        "mainEntity": [
          {
            "@type": "Question",
            "name": "Does Lovable's crawler pre-rendering still work after I deploy to Vercel?",
            "acceptedAnswer": {
              "@type": "Answer",
              "text": "No. Lovable's docs describe on-request pre-rendering for older React + Vite projects as something applied on deployed public URLs it hosts. It is a hosting-layer behavior, so it does not travel with a GitHub export or a ZIP download. Once you serve the build yourself, crawlers get whatever your own host returns."
            }
          },
          {
            "@type": "Question",
            "name": "Can I self-host Lovable itself?",
            "acceptedAnswer": {
              "@type": "Answer",
              "text": "No. Lovable's deployment and ownership documentation states that the platform itself, meaning the editor and the AI agent, is a managed service and cannot be self-hosted or deployed inside a customer VPC. What you can host anywhere is the app it generates."
            }
          },
          {
            "@type": "Question",
            "name": "What build command and output directory should I use?",
            "acceptedAnswer": {
              "@type": "Answer",
              "text": "For an older React + Vite Lovable project, the build command is npm run build and the output directory is dist, plus a catch-all rewrite to index.html. A TanStack Start project builds a server bundle rather than a folder of static files, so check the build script in package.json and deploy it to a host that runs server code."
            }
          },
          {
            "@type": "Question",
            "name": "Do I stop paying Lovable once I host the frontend myself?",
            "acceptedAnswer": {
              "@type": "Answer",
              "text": "Not necessarily. Lovable bills build usage, Cloud usage and AI Gateway usage from one credit pool, so if your app still talks to Lovable Cloud for its database, auth, storage or edge functions, that backend usage keeps drawing credits even though the frontend is served from your own host."
            }
          },
          {
            "@type": "Question",
            "name": "I am on TanStack Start. Is my dynamically loaded content actually in the HTML?",
            "acceptedAnswer": {
              "@type": "Answer",
              "text": "Not necessarily. TanStack Start renders your component tree on the server, but React documents that effects never run during server rendering and TanStack Query documents that un-prefetched queries are fetched on the client instead, so data loaded in a route loader or server function is serialized into the HTML while data fetched in a component hook is not. Curl a page with dynamically loaded content and grep for a phrase that isn't in your source code."
            }
          },
          {
            "@type": "Question",
            "name": "Is serving pre-rendered HTML to crawlers considered cloaking?",
            "acceptedAnswer": {
              "@type": "Answer",
              "text": "Not under Google's or Bing's stated rules, provided the content a crawler receives matches what a human sees. Google defines cloaking by intent to mislead plus divergent content. The content matches as long as snapshots refresh when your pages change, which a managed service like Encited handles for you."
            }
          }
        ]
      }
    ]
  }
---

[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.  /  [Migration](/migration)
3.  /  Self-hosting a Lovable app, and what you lose 

# Self-hosting a Lovable app: the deploy, and the honest ledger

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.

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

You can host a Lovable-generated app anywhere that runs static files or a Node-compatible runtime, and the deploy itself takes about ten minutes. The deploy is the easy part. What catches people is that three things stay behind on Lovable’s infrastructure when you leave: the managed hosting, the publish-snapshot workflow, and (if you are on the older React + Vite stack) the crawler pre-rendering that was quietly making your SPA indexable.

That third one is the expensive surprise, because nothing breaks visibly. Your site loads fine. Googlebot still renders JavaScript. But on the older stack, every crawler that does not execute JS now receives an empty root div where your content used to be, and no error appears anywhere in your stack.

**The short version:**

1.  Check which stack you are on before anything else. React + Vite and TanStack Start deploy differently and fail differently.
2.  Static hosts need a catch-all rewrite to `index.html`. Without it, every non-root URL 404s.
3.  Exporting code does not export your backend. Lovable Cloud stays where it is.
4.  Test a **page with dynamically loaded content** (CMS posts, product pages, directory listings: anything that isn’t written into the page’s source code) before you cut over and again after. On TanStack Start, whether your content is in the HTML is decided by your route code, whoever hosts it, so the test tells you what actually changed.

## Which stack are you on? Everything below forks here[#](#which-stack-are-you-on-everything-below-forks-here)

On 13 May 2026, Lovable changed the default stack for new projects from React + Vite (client-rendered) to TanStack Start with server-side rendering, running on Cloudflare Workers. Existing projects were not force-migrated. So there are two populations, and advice written for one is wrong for the other.

Do not trust a blog post for this, including this one. Check the manifest:

```
grep -E '"(@tanstack/react-start|@tanstack/react-router|react-router-dom)"' package.json
```

-   `@tanstack/react-start` present → new stack. SSR is on by default, so the server renders your component tree on every request. Whether your _data_ is in that HTML is a separate question with a separate answer: see the rendering section below.
-   only `react-router-dom` → older stack. Client-rendered SPA, with Lovable’s pre-rendering doing the SEO work for you while you were hosted there.

Lovable’s own docs disagree with themselves on this point: the deployment, hosting and ownership page still describes projects as standard Vite + React, and the `llms.txt` index says the same, while the SEO and TanStack pages describe SSR as the default for new apps. As of September 2026 the package manifest is the only source that is definitionally correct for _your_ project.

## Getting the code out[#](#getting-the-code-out)

Two supported routes, both covered in more depth in the [Lovable migration guide](/blog/lovable-migration) and in [how to download your Lovable code](/blog/download-lovable-code).

Git sync is the one you want for continuous deployment. Note the UI naming, because it trips people up in support threads: there is no “Export to GitHub” button. The action is **Connect**, and the docs describe it as export and two-way sync. It always creates a new repository (importing an existing repo into Lovable is explicitly unsupported) and it works with GitHub, GitLab and Bitbucket Cloud.

The alternative is the ZIP download from the code editor or from Project settings → Git. It is a point-in-time snapshot with no ongoing sync. Both routes require a paid plan; the Free tier has no code editing, no custom domains and no code downloads.

Gotcha

Do not use GitHub’s **Transfer** button to move the repo into your company org while it is still connected. Transferring to another owner or organization breaks Lovable sync and requires a support ticket to re-attach. Renaming the repository itself is safe: Lovable tracks renames automatically.

## Deploying a React + Vite build[#](#deploying-a-react--vite-build)

This is the straightforward case. `npm run build` produces a `dist/` folder of static assets that any CDN can serve.

### Environment variables[#](#environment-variables)

Vite only exposes variables prefixed with `VITE_` to client code. An unprefixed variable will not reach the browser and you get a blank screen with no useful error. The Supabase-flavored variables a Lovable export typically expects are `VITE_SUPABASE_URL`, `VITE_SUPABASE_PUBLISHABLE_KEY` and `VITE_SUPABASE_PROJECT_ID`, but confirm the exact names by grepping your own code for `import.meta.env`: Lovable’s generated names have shifted over time. Get it [running on localhost](/blog/run-exported-lovable-app-locally) before you debug any of this in a deploy log.

### The rewrite rule, per host[#](#the-rewrite-rule-per-host)

A client-rendered SPA has exactly one HTML file. Request `/pricing` on a static host with no rewrite and you get a 404, because there is no `pricing/index.html` on disk. Every host spells the fix differently.

**Vercel**: `vercel.json` at the repo root. Build command `npm run build`, output directory `dist`:

```
{
  "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }]
}
```

**Netlify**: either a `public/_redirects` file (it gets copied into `dist/` at build time) or `netlify.toml`:

```
[build]
  command = "npm run build"
  publish = "dist"

[[redirects]]
  from = "/*"
  to = "/index.html"
  status = 200
```

The `status = 200` matters. A 301 or 302 would redirect the URL; 200 rewrites it, keeping the original path in the address bar so your router can read it.

**Cloudflare Pages**: build command `npm run build`, build output directory `dist`, and a `_redirects` file with the same rewrite:

```
/*    /index.html   200
```

Your catch-all just created soft 404s

A rewrite that returns `index.html` with a 200 for _every_ path means a typo’d URL and a deleted product page both return “success” to Google. That is the classic SPA soft 404. Google documents two fixes: redirect to a URL that genuinely returns 404, or inject `<meta name="robots" content="noindex">` when your data fetch comes back empty. Pick one now, before the URLs get crawled.

Verify with:

```
curl -sI -o /dev/null -w '%{http_code}\n' https://yourdomain.com/definitely-not-a-real-page
# 200 => soft 404. 404 => correct.
```

## Deploying a TanStack Start app[#](#deploying-a-tanstack-start-app)

Different problem. TanStack Start is not a folder of static files: it builds a server bundle that has to run somewhere, and the SSR you are paying for only happens if a runtime executes it per request. Read the `package.json` scripts and the `tanstackStart()` block in `vite.config.ts` before you configure anything on the host: on Vercel or Netlify you want a framework app with server output rather than a static publish directory, and on Cloudflare you want Workers rather than a plain Pages upload, which is where Lovable runs it too.

SPA mode looks like a working deploy and is not one

If someone “fixes” a failing deploy by switching on `tanstackStart({ spa: { enabled: true } })`, the build prerenders only the root route into `/_shell.html` and renders the router’s _pending fallback_ where your matched routes would go, then rewrites 404s to that shell. Crawlers then receive a 200 response containing a loading spinner. It passes a naive status-code check and indexes as a contentless page. Verify with `curl` against the live URL.

## What stays behind on Lovable[#](#what-stays-behind-on-lovable)

Here is the ledger, honestly. Some of these you will not miss.

What you had

What happens when you self-host

Managed hosting on `*.lovable.app`, automatic HTTPS, global edge

You now own the host. Lovable’s hosting doc states your plan “does not cap visitors, requests, or bandwidth”: most CDN tiers you move to do meter something

Publish-as-snapshot

Gone, and this cuts both ways. On Lovable the live site only changes when you republish; with Git-based deploys, every merge to the tracked branch ships

In-editor preview parity

The editor preview is already a different environment from the published site. Once you self-host, your preview and your production build diverge further

One-click custom domains

Now DNS and certificates are your job

Lovable Cloud backend

Unchanged. It does not move. Code export contains migration files, not data

**Crawler pre-rendering (older React + Vite projects)**

**Gone, silently.** See below

SSR on TanStack Start

Travels with your code, provided you deploy it to a runtime. But so does every fetch your components do after hydration: self-hosting neither fixes nor breaks that

Three of these deserve more than a table row.

**The backend does not move.** Exporting your source code gives you the frontend and your edge function source. Your database rows, storage objects, auth users and secret values stay behind. Lovable’s secret store is write-only, so you re-enter every key at the destination. If you are going all the way, [moving from Lovable Cloud to your own Supabase](/blog/lovable-cloud-to-supabase) is a separate project with its own failure modes. It is also worth knowing that leaving Lovable Cloud and leaving Lovable are different decisions: you can connect your own Supabase and keep using the editor.

**Credits do not stop.** Lovable draws build usage, Cloud usage and runtime AI Gateway usage from a single credit pool. Self-hosting the frontend removes none of the Cloud line while the backend stays put.

**Your host does not decide whether your data is in the HTML.** On TanStack Start that is a property of your route code (a route `loader` or a server function versus a fetch inside a component) and it reads the same on Lovable’s Workers deployment as it does on yours. The mechanism is in the next section. The practical consequence for this migration: run the search-phrase test before you cut over and again after, so you can tell “the move broke my rendering” apart from “this route was always fetched on the client”.

## The one nobody flags: crawler pre-rendering[#](#the-one-nobody-flags-crawler-pre-rendering)

For older React + Vite projects, Lovable applies on-request pre-rendering on deployed public URLs it hosts, and per its docs that rendered HTML is served **only to verified crawlers**: Google, Bing, social preview bots, and AI engines like ChatGPT, Perplexity, Claude and Gemini. Humans get the normal SPA.

Notice what kind of thing that is. It lives at the hosting layer and applies only to URLs Lovable serves, so nothing in your `package.json` or repo carries it over. When you deploy the same commit to Vercel, Cloudflare Pages or Netlify as a static Vite build, nothing in your code changed and nothing in your build changed, but the HTML a crawler receives becomes `<div id="root"></div>`.

Lovable’s docs say external pre-rendering services are unnecessary for Lovable-hosted projects. That is true, and it stays true right up to the moment you stop being a Lovable-hosted project.

### If you are on TanStack Start, this is a different question[#](#if-you-are-on-tanstack-start-this-is-a-different-question)

You are not relying on a user-agent branch any more, because the server renders every request. That’s a real improvement, and there’s still more to check.

**SSR renders your components. It only includes data that a route loader fetched.** React’s documentation is explicit: effects _“only run on the client. They don’t run during server rendering.”_ The server renders your component in its initial state, so the HTML ships the loading branch. TanStack Query documents the same behavior for queries that were never prefetched: they _“wont be server rendered, instead they will be fetched on the client after the application is interactive.”_ TanStack Start’s own Query guide states the fix in one line: _“The route awaits the query for content that must appear in the initial HTML.”_

So a page can be one hundred percent server-rendered and contain zero rows from your database. The HTML faithfully reproduces the skeleton. Where the fetch lives decides the outcome:

Where the data is fetched

In the initial HTML?

Route `loader`, or a `createServerFn` the loader awaits

Yes: loaders are isomorphic and their data is serialized into the document

`useEffect` in a component

No: effects do not run during server rendering

`useQuery` that was never prefetched on the route

No: it runs after hydration

Lovable’s own description of the new stack is conditioned on exactly this: the server “runs the React tree, **executes the loaders**, and streams the result.” Loaders. That is the hinge, and it is the thing to check in your own repo. As of September 2026, Lovable’s docs do not say which pattern its generated code uses for data-backed routes, so do not take my word or theirs: grep your routes for `loader` and `useEffect`, then run the test below.

For what it’s worth, 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, while nearly half fetched it in the browser. Check your own routes anyway; it takes one curl each.

“SSR is enabled” and “my product list is in the HTML” are two independent facts. Self-hosting does not change where your fetch lives, but it can change whether SSR runs at all, if the build lands in a static bucket or in SPA mode. That is why you measure both sides of the move.

### Why this is not “Google can’t read React”[#](#why-this-is-not-google-cant-read-react)

Be precise about who actually breaks, because the panic version of this claim is wrong and a technical reader will spot it.

Googlebot renders JavaScript. Measurement by Vercel and MERJ across 37,000+ matched server and beacon pairs put the median render-queue delay at about 10 seconds, with a p75 of 26 seconds: the long tail reaches hours, but “Google can’t see your React app” has not been true for years. Bingbot claims JavaScript capability while admitting it is difficult to do at scale, and Bing still recommends dynamic rendering. Apple’s own documentation says Applebot may render page content in a browser.

The crawlers that do not render JavaScript are the interesting ones. Vercel and MERJ’s December 2024 crawler study found that none of the major AI crawlers execute JavaScript: GPTBot, OAI-SearchBot, ChatGPT-User, ClaudeBot, Meta-ExternalAgent, Bytespider and PerplexityBot. That measurement is from late 2024 and I have not found a first-party re-measurement since, so treat it as a well-sourced snapshot that may have changed. Add every link unfurler (Slack’s own docs describe an HTTP Range fetch with no rendering) and the picture is clear: you can rank acceptably in Google, appear in AI Overviews, and be completely absent from ChatGPT, Claude and Perplexity, with no Google-based tool in your stack showing you anything is wrong.

### Verify it yourself, before and after[#](#verify-it-yourself-before-and-after)

Thirty seconds of `curl` beats any vendor’s diagram, but only if you point it at the right page. Use a route whose content comes out of your database: `/products`, `/blog`, a listing, a detail page. The homepage is usually hardcoded marketing copy and will happily tell you everything is fine. Then pick a phrase that isn’t in your source code.

```
URL="https://yourapp.lovable.app/products"
PHRASE="Ceramic Pour-Over Kettle"   # a phrase that isn't in your source code

curl -s -A 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)' \
  "$URL" > /tmp/page.html

# Strip scripts and styles first: a search phrase found inside a serialized JSON
# payload is not body content and no crawler reads it as such.
perl -0pe 's/<(script|style)\b.*?<\/\1>//gsi' /tmp/page.html > /tmp/page.clean.html

grep -qiF "$PHRASE" /tmp/page.clean.html \
  && echo 'server-rendered: the data is in the HTML' \
  || echo 'client-fetched: a crawler that does not run JS never sees it'
```

Four things will make you misread the result, and all four are common:

1.  **Strip `<script>` and `<style>` before counting or grepping.** TanStack serializes router and loader state into the document, and third-party widgets inline JSON of their own, so a raw `grep` over the whole file can match your search phrase inside a script payload and report “server-rendered” for text no crawler reads as body content. The same applies doubly to word counts, which otherwise mostly count inline CSS.
2.  **The search phrase must be one contiguous run of text.** Markup splits phrases across elements, so a three-word product name fails if the last word sits in its own `<span>`. Shorten the phrase until it is safe.
3.  **On Lovable’s React + Vite stack, this can’t be verified.** The command returns the SPA shell whatever user agent you send, and Lovable’s docs say pre-rendered HTML goes only to crawlers it verifies. The curl result still shows what unverified clients see, which includes most SEO scanners.
4.  **Identical bytes on two requests prove nothing about caching.** Deterministic SSR returns identical bytes too. Read `cf-cache-status` and `age` on the response before you conclude anything about revalidation.

One more failure mode worth knowing, because almost nobody writes about it. Streaming SSR with Suspense can flush resolved content into a `<div hidden id="S:0">…</div>` late in the document, with a `$RC(...)` script that moves it into place on the client. Your `grep` finds the text. Whether a given crawler counts it as body content is a different question. If your search phrase only appears inside a hidden div, treat the result as unconfirmed and check the rendered HTML in Search Console.

Now run the identical command against your self-hosted URL after cutover and compare the two results.

These three readings only hold when the before-side is a TanStack Start app, or any host that does not gate pre-rendering on crawler verification. On React + Vite hosted by Lovable, curl misses before the move whatever user agent you send, and what verified crawlers receive can’t be verified from outside, so compare indexing in Search Console before and after instead.

-   **Phrase present before, missing after**: the move cost you something: a loader that no longer runs, or a deploy serving a shell instead of SSR (see the SPA-mode gotcha above). On React + Vite, the equivalent evidence is URL Inspection showing rendered content before and the raw shell after; curl cannot show you that side.
-   **Missing before and after, both sides curl-tested on TanStack Start**: nothing broke in the move. That route fetches its data on the client and always did. This is the case people mistake for a migration bug.
-   **Present before and after**: the data is coming from a loader or a prerendered file, and it survived the move.

A second check, for link previews and per-route metadata:

```
for route in / /pricing /blog/hello-world; do
  echo "=== $route ==="
  curl -sL -A 'facebookexternalhit/1.1' "https://yourdomain.com$route" \
    | grep -oE '<title>[^<]*</title>|<meta[^>]*og:[^>]*>'
done
```

Run this against your self-hosted origin. Against a Lovable-hosted React + Vite URL any user agent gets the shell, so identical titles there tell you nothing. Check a real share preview instead.

Identical `og:title` values across all three routes means every share of every page shows the same card. That is the default state of an unfixed SPA.

## Three ways to fix it, ranked by how permanent they are[#](#three-ways-to-fix-it-ranked-by-how-permanent-they-are)

### 1\. Move to a rendering framework[#](#1-move-to-a-rendering-framework)

The most durable answer, and the one Google recommends over dynamic rendering. Three realistic paths:

-   **Upgrade the Lovable project to TanStack Start**, then export. It is triggered from chat or project settings, requires the older React + Vite stack and edit permissions, costs credits, and is reversible from version history. Preserved: page protection, titles and descriptions, analytics scripts, theme, styling and external integration URLs. The documented risk is real though: some browser-only libraries break server rendering in ways the upgrade’s checks do not catch, so test after. And remember the deploy consequence: you now need a host that runs server code.
-   **React Router v8 framework mode**, if you would rather stay in your own repo. `react-router.config.ts` accepts `ssr: true`, and a `prerender()` function returning a list of URLs, and the hybrid of both. Verify the current version numbers against the React Router docs before you pin anything.
-   **Port to Next.js or Astro.** Highest cost, widest ecosystem, and rarely worth it purely for rendering.

Trade-off: SSR only fixes the initial HTML. Anything your page fetches client-side after hydration is still invisible to a crawler that does not execute JS. That distinction is the whole subject of [SSR versus prerendering on TanStack Start](/blog/tanstack-start-ssr-vs-prerendering).

### 2\. Prerender the routes you can at build time[#](#2-prerender-the-routes-you-can-at-build-time)

Cheapest correct fix for a marketing site with enumerable routes. Zero runtime cost, perfect cacheability, and it works for every crawler equally because there is no user-agent branch to get wrong.

TanStack Start ships this as a first-class option, and note the defaults, because auto-discovery skips routes with path params like `/users/$userId`, underscore-prefixed layout routes and component-less API routes:

```
// vite.config.ts
import { defineConfig } from 'vite'
import { tanstackStart } from '@tanstack/react-start/plugin/vite'

export default defineConfig({
  plugins: [
    tanstackStart({
      prerender: {
        enabled: true,
        crawlLinks: true,
        failOnError: true,
        filter: ({ path }) => !path.startsWith('/app'), // keep the private app out
      },
    }),
  ],
})
```

If you are staying on plain Vite, a post-build script that writes per-route `<head>` tags into copies of the shell will fix titles, canonicals and OG cards for every crawler. Be honest with yourself about its limit: the `<body>` is still an empty root div, so this repairs link previews and per-route metadata only.

One limit to be clear about: prerendering renders the same server output you would get at request time and writes it to a file. A route that fetches in a component hook prerenders its skeleton, exactly as it server-renders its skeleton. Prerendering fixes _when_ the HTML is produced, not _where_ your fetch lives.

It also does nothing for routes you cannot enumerate. A dashboard, a user profile, a search results page, anything behind an ID: build-time prerendering has no answer for those.

### 3\. Put prerender middleware at the edge[#](#3-put-prerender-middleware-at-the-edge)

This is the option that replaces, one for one, [what Lovable’s pre-rendering was doing for you](/blog/lovable-prerendering): sniff the user agent, and for known bots serve rendered HTML from a cache while humans get the untouched SPA. It requires no change to your application code, which is precisely why it exists.

Self-hosting it means running headless Chromium in production, and that is a real operational commitment: memory pressure, zombie processes, and a 504 for Googlebot when a render hangs. [Encited](https://encited.com) is the managed renderer we’d use; [Rendertron](https://encited.com/blog/best-rendertron-alternatives) and a DIY Puppeteer or Playwright service are the build-it-yourself versions.

The Worker in front of your Pages deployment looks roughly like this. It calls Encited’s render API, with the key stored as a Worker secret named `ENCITED_API_KEY`; if you run your own renderer, swap that one fetch. The guards matter more than the happy path:

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

// Never prerender assets: that is what burns your render quota.
const ASSET = /\.(js|mjs|css|json|xml|txt|map|png|jpe?g|gif|svg|webp|avif|ico|woff2?|ttf|mp4|pdf)$/i

export default {
  async fetch(request, env, ctx) {
    const url = new URL(request.url)
    const ua = request.headers.get('user-agent') || ''
    const isGet = request.method === 'GET' || request.method === 'HEAD'
    // Recursion guard: the renderer fetches this origin back. Pass its requests
    // straight through, matching whatever header or user agent it sends.
    const selfFetch = request.headers.has('x-prerender')

    if (!ua || !isGet || selfFetch || ASSET.test(url.pathname) || !BOT.test(ua)) {
      return fetch(request)
    }

    const cache = caches.default
    const key = new Request(url.origin + url.pathname + url.search, { method: 'GET' })
    const hit = await cache.match(key)
    if (hit) return hit

    let res
    try {
      res = await fetch(
        `https://encited.com/api/prerender/render?url=${encodeURIComponent(url.toString())}`,
        {
          headers: { Authorization: `Bearer ${env.ENCITED_API_KEY}` },
          signal: AbortSignal.timeout(15_000),
          redirect: 'manual',
        },
      )
    } catch {
      return fetch(request) // fail open: a dead renderer must never 5xx your site
    }
    // 301: a redirect you configured, pass it to the client.
    // 304: pre-rendering didn't apply to this URL, fall back to the origin.
    if (res.status === 301) return res
    if (res.status !== 200) return fetch(request)

    const out = new Response(res.body, res)
    out.headers.set('Cache-Control', 'public, max-age=600, s-maxage=86400')
    out.headers.set('Vary', 'User-Agent')
    ctx.waitUntil(cache.put(key, out.clone()))
    return out
  },
}
```

If you run this Worker yourself, four things will bite you over the following year:

1.  **Content divergence.** The app ships, the snapshot does not. Parity checks belong in CI, because a snapshot serving last quarter’s pricing is a genuine content mismatch.
2.  **Bot-list drift.** New agents appear constantly. An unmaintained allowlist silently excludes them and quietly reintroduces the exact failure you deployed this to fix. (This applies to Lovable’s “verified crawlers” list too: you just were not the one maintaining it.)
3.  **Cold renders.** The first bot hit on an uncached route pays full headless-browser latency.
4.  **Spoofing.** User-agent strings are trivially faked, so treat bot-branch content as public.

None of that makes it cloaking. Google defines cloaking as presenting different content to users and search engines with intent to manipulate and mislead; Bing states outright that serving server-rendered content to bots and client-rendered content to users in good faith is acceptable. Google does call dynamic rendering “a workaround and not a recommended solution” because of the complexity involved. It hasn’t made it a policy violation or set a deprecation date.

If you’d rather not deploy a Worker at all, Encited also installs as prebuilt middleware for [Vercel](https://encited.com/docs/domain-setup/setup-with-vercel), [Cloudflare](https://encited.com/docs/domain-setup/setup-with-cloudflare-workers) and [Netlify](https://encited.com/docs/domain-setup/setup-with-netlify-edge-functions), or with a [DNS-level setup](https://encited.com/docs/pre-rendering/setup-no-code-via-dns) and no code changes. It serves Markdown to AI agents alongside HTML for search bots, and logs which bot hit which URL and whether it got a snapshot. If your routes are enumerable, build-time prerendering is cheaper than any of this.

## So should you self-host at all?[#](#so-should-you-self-host-at-all)

A fair summary of the decision:

Your situation

Reasonable call

TanStack Start project, mostly marketing pages, happy with the editor

Stay on Lovable hosting. You gain little and take on SSR deployment

React + Vite project you want on your own CDN

Self-host, but budget for one of the three rendering fixes in the same sprint

You need CI, preview branches, tests, or a real review process

Self-host. The publish-snapshot model is not a deployment pipeline

You are leaving because of cost

Model it fully. Frontend hosting is the cheap layer; Cloud usage is what draws credits

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

1.  Run the `grep` on `package.json` and write down which stack you are on.
2.  Pick a page with dynamically loaded content and a phrase from your own database, and run the `grep` test against your published `.lovable.app` URL today. Save the output. This is your baseline and it disappears the moment you cut over. On React + Vite, run URL Inspection in Search Console for the same URL, because curl cannot see what a verified crawler gets.
3.  Deploy to a preview URL on your chosen host, not to your apex domain, and re-run the same commands there.
4.  Compare against the three outcomes above. A phrase missing on both sides of a TanStack Start move is a route-code problem. Move the fetch into a route loader or a server function. Build-time prerendering will not rescue that one: prerendering writes the same server output to a file, so a component-hook fetch prerenders as a skeleton too. Only a headless render that waits for the network captures that data.
5.  Check the soft-404 status code on a nonsense URL once the rewrite is live.

Migrate the DNS last. Everything above is reversible while your domain still points at Lovable.

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

-   [Which stack are you on? Everything below forks here](#which-stack-are-you-on-everything-below-forks-here)
-   [Getting the code out](#getting-the-code-out)
-   [Deploying a React + Vite build](#deploying-a-react--vite-build)
-   [Environment variables](#environment-variables)
-   [The rewrite rule, per host](#the-rewrite-rule-per-host)
-   [Deploying a TanStack Start app](#deploying-a-tanstack-start-app)
-   [What stays behind on Lovable](#what-stays-behind-on-lovable)
-   [The one nobody flags: crawler pre-rendering](#the-one-nobody-flags-crawler-pre-rendering)
-   [If you are on TanStack Start, this is a different question](#if-you-are-on-tanstack-start-this-is-a-different-question)
-   [Why this is not “Google can’t read React”](#why-this-is-not-google-cant-read-react)
-   [Verify it yourself, before and after](#verify-it-yourself-before-and-after)
-   [Three ways to fix it, ranked by how permanent they are](#three-ways-to-fix-it-ranked-by-how-permanent-they-are)
-   [1\. Move to a rendering framework](#1-move-to-a-rendering-framework)
-   [2\. Prerender the routes you can at build time](#2-prerender-the-routes-you-can-at-build-time)
-   [3\. Put prerender middleware at the edge](#3-put-prerender-middleware-at-the-edge)
-   [So should you self-host at all?](#so-should-you-self-host-at-all)
-   [What to do next](#what-to-do-next)

[More on Migration](/migration)

-   [Getting your code out of Lovable: the full guide](/blog/lovable-migration)
-   [How to download your code from Lovable](/blog/download-lovable-code)
-   [Lovable Cloud vs your own Supabase: how to choose](/blog/lovable-cloud-vs-supabase)
-   [Lovable GitHub sync: how it really works](/blog/lovable-github-sync)
-   [Migrating from Lovable Cloud to your own Supabase](/blog/lovable-cloud-to-supabase)
-   [Moving a Lovable project to a different repo](/blog/move-lovable-project-to-another-repo)
-   [Running an exported Lovable app on your machine](/blog/run-exported-lovable-app-locally)
-   [Self-hosting a Lovable app, and what you lose](/blog/self-host-lovable-app)

## Frequently asked questions

Does Lovable's crawler pre-rendering still work after I deploy to Vercel?

No. Lovable's docs describe on-request pre-rendering for older React + Vite projects as something applied on deployed public URLs it hosts. It is a hosting-layer behavior, so it does not travel with a GitHub export or a ZIP download. Once you serve the build yourself, crawlers get whatever your own host returns.

Can I self-host Lovable itself?

No. Lovable's deployment and ownership documentation states that the platform itself, meaning the editor and the AI agent, is a managed service and cannot be self-hosted or deployed inside a customer VPC. What you can host anywhere is the app it generates.

What build command and output directory should I use?

For an older React + Vite Lovable project, the build command is npm run build and the output directory is dist, plus a catch-all rewrite to index.html. A TanStack Start project builds a server bundle rather than a folder of static files, so check the build script in package.json and deploy it to a host that runs server code.

Do I stop paying Lovable once I host the frontend myself?

Not necessarily. Lovable bills build usage, Cloud usage and AI Gateway usage from one credit pool, so if your app still talks to Lovable Cloud for its database, auth, storage or edge functions, that backend usage keeps drawing credits even though the frontend is served from your own host.

I am on TanStack Start. Is my dynamically loaded content actually in the HTML?

Not necessarily. TanStack Start renders your component tree on the server, but React documents that effects never run during server rendering and TanStack Query documents that un-prefetched queries are fetched on the client instead, so data loaded in a route loader or server function is serialized into the HTML while data fetched in a component hook is not. Curl a page with dynamically loaded content and grep for a phrase that isn't in your source code.

Is serving pre-rendered HTML to crawlers considered cloaking?

Not under Google's or Bing's stated rules, provided the content a crawler receives matches what a human sees. Google defines cloaking by intent to mislead plus divergent content. The content matches as long as snapshots refresh when your pages change, which a managed service like Encited handles for you.

Read next

## Keep going

-   ### [Getting your code out of Lovable: the full guide](/blog/lovable-migration)
    
    A Lovable migration is really three migrations: frontend, backend, hosting. What each export path moves, which one-way doors break sync, and the safe order.
    
-   ### [Pre-rendering a Lovable app: when you need it](/blog/lovable-prerendering)
    
    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.
    
-   ### [Running an exported Lovable app on your machine](/blog/run-exported-lovable-app-locally)
    
    Blank white screen after export? Run Lovable app locally without the guesswork: pick the right Node, fix the missing env vars, and handle the backend that stayed behind.
    
-   ### [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.
    

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)