---
title: "TanStack Start SSR vs prerendering | Lovable Field Guide"
description: "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."
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/tanstack-start-ssr-vs-prerendering#article",
        "headline": "TanStack Start SSR vs prerendering",
        "description": "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.",
        "datePublished": "2026-09-11T00:00:00.000Z",
        "dateModified": "2026-09-13T00:00:00.000Z",
        "inLanguage": "en",
        "mainEntityOfPage": {
          "@type": "WebPage",
          "@id": "https://prerenderlovable.com/blog/tanstack-start-ssr-vs-prerendering"
        },
        "author": {
          "@type": "Organization",
          "name": "The Lovable Field Guide",
          "url": "https://prerenderlovable.com"
        },
        "publisher": {
          "@id": "https://prerenderlovable.com/#organization"
        },
        "keywords": "tanstack start ssr vs prerendering, tanstack start spa mode, tanstack start prerendering, lovable tanstack start upgrade, does ssr fix seo",
        "articleSection": "SEO"
      },
      {
        "@type": "BreadcrumbList",
        "@id": "https://prerenderlovable.com/blog/tanstack-start-ssr-vs-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": "TanStack Start SSR vs prerendering",
            "item": "https://prerenderlovable.com/blog/tanstack-start-ssr-vs-prerendering"
          }
        ]
      },
      {
        "@type": "FAQPage",
        "@id": "https://prerenderlovable.com/blog/tanstack-start-ssr-vs-prerendering#faq",
        "mainEntity": [
          {
            "@type": "Question",
            "name": "Does TanStack Start SSR mean crawlers see everything on my page?",
            "acceptedAnswer": {
              "@type": "Answer",
              "text": "No. Server-side rendering renders your components, and it only includes data that a route loader fetched. The response contains your components in their initial state plus whatever the route loader returned and serialized into the document. React's own documentation notes that effects do not run during server rendering, so anything a component fetches after it mounts happens in a browser after the HTTP response has already closed. A crawler that reads the response bytes and stops never sees that content."
            }
          },
          {
            "@type": "Question",
            "name": "Does TanStack Start support ISR?",
            "acceptedAnswer": {
              "@type": "Answer",
              "text": "No. TanStack Start has no ISR feature: no isr, swr, revalidate or staleMaxAge key exists in the plugin's configuration schema. What the documentation files under that heading is build-time prerendering combined with per-route Cache-Control headers you write yourself. On the official Cloudflare Workers setup with default config nothing is cached or revalidated: the HTML is composed fresh in the Worker on every request."
            }
          },
          {
            "@type": "Question",
            "name": "How much does upgrading a Lovable project to TanStack Start cost?",
            "acceptedAnswer": {
              "@type": "Answer",
              "text": "The upgrade costs credits. It requires the older React + Vite stack and edit permissions, only one upgrade can run per project at a time, and it is fully reversible from version history without affecting your published site."
            }
          },
          {
            "@type": "Question",
            "name": "Is TanStack Start stable enough to run in production?",
            "acceptedAnswer": {
              "@type": "Answer",
              "text": "As of September 2026 the public position is a v1.0 Release Candidate, announced on 23 September 2025, with the API described as feature-complete and stable but the build acknowledged as not bug-free. A general-availability 1.0 could not be verified against tanstack.com. Lovable ships it as the default stack for new projects regardless, which is its own kind of production signal."
            }
          },
          {
            "@type": "Question",
            "name": "Does TanStack Start's SPA mode help SEO?",
            "acceptedAnswer": {
              "@type": "Answer",
              "text": "No, and it can actively mislead you. SPA mode prerenders only the root route into a shell file and renders the router's pending fallback where matched routes would go. A crawler gets a 200 response containing real HTML (which passes a naive check) but the body holds a loading state rather than your content. It's only a setting you'd meet in a build you run yourself; you can't switch it on in a Lovable project."
            }
          },
          {
            "@type": "Question",
            "name": "Does Lovable's generated TanStack Start code fetch data in route loaders?",
            "acceptedAnswer": {
              "@type": "Answer",
              "text": "Usually not. In a September 2026 study of 82 Lovable apps on GitHub, only 4.4% of page routes loaded their data in a route loader, and fewer still in apps built since June. Most fetched it inside the component with useEffect or useQuery, and 48 of 78 apps queried Supabase directly from the browser."
            }
          },
          {
            "@type": "Question",
            "name": "Do I still need a pre-rendering layer if I already have SSR?",
            "acceptedAnswer": {
              "@type": "Answer",
              "text": "Only if content that matters reaches the page after hydration and you cannot move it into a server-side loader. Pre-rendering runs the page in a headless browser, waits for the client-side fetches to finish, and caches the resulting HTML for crawlers. Even when your content already comes from loaders, a pre-render layer adds what SSR doesn't: a Markdown version of each page for AI agents, SEO fixes on every snapshot, and logs of what each bot received."
            }
          }
        ]
      }
    ]
  }
---

[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.  /  TanStack Start SSR vs prerendering 

# 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.

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

Server-side rendering renders your components, and it only includes data that a route loader fetched. A TanStack Start route can be 100% server-rendered and still ship HTML containing nothing but your loading skeleton, because what decides whether your content is in the response is _where the fetch lives_: a route `loader` or `createServerFn` puts it there, a component hook, `useEffect` or an un-prefetched `useQuery` does not. Pre-rendering is the answer to the second case and not a substitute for the first. Telling the two apart on your own URLs takes about thirty seconds with `curl`, and almost nobody does it.

This post is the technical background for the rest of our SEO guides, because since 13 May 2026 this is the stack. If you want the wider picture first, start with [the Lovable SEO overview](/blog/lovable-seo).

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

-   **SSR only includes data that a route loader fetched.** The server runs your components in their initial state and serializes what the route loader returned. Anything a component fetches after it mounts is not in the response.
-   **“SSR is enabled” and “my content is in the HTML” are independent facts.** Where the fetch lives decides the second one, and only the second one affects what a non-rendering crawler reads.
-   **New Lovable projects (13 May 2026 onward) are TanStack Start with SSR on Cloudflare Workers.** Existing React + Vite projects were not force-migrated; Lovable serves them on-request pre-rendering to _verified crawlers only_.
-   **You can predict which side you’re on before you test.** In [the Lovable apps we studied](/blog/lovable-tanstack-ssr-study), only 4.4% of pages load their data in a route loader. Unless you asked Lovable for one by name, assume you didn’t get one.
-   **TanStack Start ships SSR on (`ssr: true`) and prerendering off (`prerender: { enabled: false }`).** There is no ISR feature: no `isr`, `swr`, `revalidate` or `staleMaxAge` key exists in the plugin config. On Lovable you can’t change any of these settings.
-   **SPA mode is a trap if you treat it as an SEO feature.** It prerenders a shell containing the pending fallback: a 200 with a spinner in it.
-   **A pre-render layer works alongside SSR.** It earns its keep on exactly one axis: content that arrives after hydration and that you cannot move into a loader.
-   **The upgrade from React + Vite is semi-automatic via chat**, costs credits, and is reversible from version history.

## SSR only includes data that a loader fetched[#](#ssr-only-includes-data-that-a-loader-fetched)

Here is the failure mode. SSR is on. `curl` returns a wall of HTML. The `<title>` is per-route and correct. Someone ticks the box and moves on.

What the response actually contains is your component tree rendered in its initial state, plus **server-injected data**: in TanStack Start, essentially whatever the route’s loader returned, serialized into the document. That is not a Lovable quirk or a TanStack quirk. It falls out of how React renders on a server, and the documentation says so plainly.

React’s `useEffect` docs: _“Effects only run on the client. They don’t run during server rendering.”_ The server renders the loading branch, and the loading branch is what ships.

The same point appears in TanStack Query’s SSR guide, about queries you never prefetch: 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 rule positively: _“The route awaits the query for content that must appear in the initial HTML.”_

Await it in the route and it is in the HTML. Fire it from a component and it is not. A route `loader`, or a `createServerFn` called from one, or a `useQuery` that the loader prefetched and dehydrated, all land in the response body. A bare `useEffect`, an un-prefetched `useQuery`, or a third-party script that injects content, all land in a browser some time later. Everything else in this post is a consequence of that split.

Notice that Lovable’s engineering blog is worded to match. The server, it says, “runs the React tree, **executes the loaders**, and streams the result.” Loaders specifically. The condition is right there in the sentence, and it is easy to read past on the way to the word “SSR”.

### Which crawlers this actually costs you[#](#which-crawlers-this-actually-costs-you)

The consumers that only read the response bytes are the ones this costs you, and that set is large. Slack’s unfurler fetches partial byte ranges. Meta’s crawler parses the first 1MB of raw HTML. Every major AI crawler measured by Vercel and MERJ in December 2024 (GPTBot, OAI-SearchBot, ChatGPT-User, ClaudeBot, Meta-ExternalAgent, Bytespider, PerplexityBot) was observed executing no JavaScript at all. Apple is the exception in both directions: Applebot’s own documentation says it “may render the content of your website within a browser”. The other vendors document nothing about rendering either way, so for them the evidence is entirely third-party observation, and it dates from December 2024. Googlebot does render, so it will eventually catch content that arrives after hydration; the caveats there are queue delay and no guarantee of indexing. The full per-crawler breakdown lives in [SSR vs SPA for SEO](/blog/ssr-vs-spa-seo).

### Example 1: the product page whose reviews load after mount[#](#example-1-the-product-page-whose-reviews-load-after-mount)

```
// src/routes/products/$slug.tsx
export const Route = createFileRoute('/products/$slug')({
  // Runs on the server during SSR. This data IS in the response body.
  loader: ({ params }) => getProduct({ data: params.slug }),
  component: ProductPage,
})

function ProductPage() {
  const product = Route.useLoaderData()
  const [reviews, setReviews] = useState(null)

  // Runs ONLY in a browser, after hydration. Never in the HTTP response.
  useEffect(() => {
    fetch(`/api/reviews/${product.slug}`)
      .then((r) => r.json())
      .then(setReviews)
  }, [product.slug])

  return (
    <article>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <Reviews data={reviews} />   {/* empty in the SSR output */}
    </article>
  )
}
```

Both halves of that component are server-rendered. Only one half has data in it. The name and description are indexable because they came from the loader. The reviews (the long-tail text, the specific phrasing real customers use, the exact thing that makes a product page rank) are not, because they came from a hook. A human sees a complete page. A crawler that doesn’t run JavaScript (which, on the Vercel and MERJ measurements above, is every major AI crawler) sees a product with no reviews.

Fixing it is usually free. Move the fetch into the loader:

```
export const Route = createFileRoute('/products/$slug')({
  loader: async ({ params }) => {
    const [product, reviews] = await Promise.all([
      getProduct({ data: params.slug }),
      getReviews({ data: params.slug }),
    ])
    return { product, reviews }   // both now serialized into the HTML
  },
  component: ProductPage,
})
```

The component changes with it: `const { product, reviews } = Route.useLoaderData()`, and `<Reviews data={reviews} />` loses its `useState` and `useEffect` entirely.

A `useQuery` that is never prefetched and dehydrated during the loader has exactly the same hole, for exactly the same reason. The library is not the problem. The timing is.

### Example 2: the dashboard list nobody meant to index[#](#example-2-the-dashboard-list-nobody-meant-to-index)

```
function TeamDirectory() {
  const [members, setMembers] = useState([])
  useEffect(() => {
    fetch('/api/team').then((r) => r.json()).then(setMembers)
  }, [])
  return <ul>{members.map((m) => <li key={m.id}>{m.name}</li>)}</ul>
}
```

This pattern is everywhere in AI-generated code, because it is the first thing anyone learns, in the Lovable apps we studied, 44.6% of pages fetch this way, against 4.4% that use a loader. On a genuinely private dashboard it is fine: nothing should index it. The problem is when the same pattern lands on a public directory page, a pricing comparison table, a location list, or a blog index. The route SSRs. The response is 200 with valid HTML. The `<ul>` is empty.

The empty-list soft 404

A category page whose items arrive after hydration renders server-side as a heading and an empty list. Google may classify it as thin or as a soft 404, and if it does, no amount of internal linking rescues the items that were only ever listed there.

### The streaming case: “it’s in the bytes” is not “it’s on the page”[#](#the-streaming-case-its-in-the-bytes-is-not-its-on-the-page)

TanStack Start streams its SSR output, and streaming has a wrinkle that almost nothing written about this covers. With React’s streaming SSR plus Suspense, content that resolves after the initial flush does not arrive in place. It arrives later in the document inside a hidden container (something shaped like `<div hidden id="S:0">…</div>`) followed by an inline `$RC(...)` call that relocates it into the slot where the fallback is currently sitting.

So a `grep` for your product name will find it. A consumer that executes the inline script sees the assembled page. A parser that takes the DOM as delivered sees a hidden div and a spinner where your content should be. When your search phrase turns up in the response, check _where_ it turned up before declaring victory: “the string is in the bytes” and “a crawler reads it as page content” are not the same claim.

## What the TanStack Start switch actually changed[#](#what-the-tanstack-start-switch-actually-changed)

Lovable’s old stack was Vite + React with React Router, client-rendered only, built to static files on Cloudflare Pages, with server logic in separately deployed edge functions and secrets held in Supabase.

The new default, per Lovable’s own engineering blog and its SEO docs, is TanStack Start on TanStack Router, with per-route SSR, SSG or CSR, running on Cloudflare Workers. Server logic moved into the app itself via TanStack Server Functions rather than living in separately deployed functions, and secrets are injected at request time as Cloudflare Workers bindings, which is what lets them be rotated without a rebuild. TypeScript, Tailwind CSS and shadcn/ui did not change.

```
// TanStack Start server function: canonical API, not Lovable-specific syntax.
// Server logic now lives in the app instead of a separately deployed edge function.
import { createServerFn } from '@tanstack/react-start'

export const getDashboardStats = createServerFn({ method: 'GET' })
  .handler(async () => {
    // Runs on Cloudflare Workers. Secrets arrive as Workers bindings,
    // injected per request rather than baked into the bundle.
    const rows = await db.query('select count(*) from orders')
    return { orderCount: rows[0].count }
  })
```

Called from a route loader, that function’s return value lands in the HTML. Called from a component effect, it does not. Same function, same stack, opposite outcome, which is the point of the section above.

Lovable documents the SEO consequence in its own terms: new apps return fully rendered HTML for humans and crawlers, while older React + Vite apps get on-request pre-rendering served **only to verified crawlers**: Google, Bing, social bots and AI engines. Third-party scanners are not on that list, which is why a Screaming Frog or Ahrefs audit of an old-stack Lovable site reports it as contentless. What Googlebot receives can’t be verified from outside.

Read the first half of that against the mechanism, though. “Fully rendered HTML” is an accurate statement about the _rendering pipeline_; it is not a promise about your database rows. The HTML is fully rendered. Whether it is fully populated depends on where your routes fetch, and going by the study below, Lovable’s generator lands on the wrong side of that split far more often than not.

Lovable's docs contradict themselves on this

The deployment-hosting-ownership page still describes projects as “standard Vite + React”, and the `llms.txt` index summary still says React + Vite. The blog post, the SEO docs and the TanStack upgrade page say otherwise. Cite the latter, and don’t be surprised when an AI assistant quoting Lovable’s own docs tells you the stack is Vite.

### The upgrade path for existing projects[#](#the-upgrade-path-for-existing-projects)

If your project predates the switch, you can move it. Per Lovable’s upgrade doc, you trigger it from chat, project settings, or by simply asking, then confirm.

-   **Cost:** it uses credits.
-   **Requires:** the older React + Vite stack, and edit permissions.
-   **Preserved:** page protection (sign-in and admin checks), titles, descriptions, analytics scripts, theme, styling, app-specific backend code, and external integration URLs such as payment providers.
-   **Reversible:** fully, from version history, without affecting your published site.
-   **Limits:** one upgrade per project at a time; PWA users may need to clear cache manually.

The documented risk is worth quoting rather than paraphrasing: some code libraries only work in the browser and can break server rendering in ways the upgrade’s checks do not catch. That is the entire isomorphic-code problem in one sentence. Anything touching `window`, `document` or `localStorage` at module scope now runs on a Worker where none of those exist. Test every route after the upgrade, and test the ones with charting libraries, map widgets and editors first.

One thing the upgrade does not do is relocate your fetches. A `useEffect` that fetched your listings on the old stack is still a `useEffect` on the new one: now inside a server-rendered shell instead of an empty one. That is a real improvement for your shell, your headings and your metadata, and it changes nothing about the listings.

On TanStack Start's version status

As of September 2026, the honest statement is that TanStack Start reached a **v1.0 Release Candidate**, announced 23 September 2025, with the API declared feature-complete and stable while acknowledging the build is “not bug-free or without issues.” A general-availability 1.0 could not be verified against tanstack.com during research: a secondary source claiming GA in March 2026 did not corroborate. Build tooling is Vite or Rsbuild; Vinxi and Nitro were dropped.

## Which side does the generated code actually land on?[#](#which-side-does-the-generated-code-actually-land-on)

Hooks, almost always.

In September 2026 we [read the route files of 82 Lovable apps on GitHub](/blog/lovable-tanstack-ssr-study) to find out. Only 4.4% of their pages load data in a route loader, and most of those sit in one app’s admin screens. Nearly half fetch it in `useEffect` or `useQuery` inside the component, and most of the apps query Supabase straight from the browser. Even Lovable’s own starter code describes its example server function as a “Server-side handler invoked from the client”, meaning the browser calls it after the page has loaded.

The clearest example is a public blog post page with no `loader:` and no `head:`. It queries the `blogs` table in a `useEffect`, so the server-rendered HTML for every post on that site is a spinner.

The layout and static copy are server-rendered, as View Source will show you. What’s missing is the dynamically loaded content: CMS posts, product pages, directory listings, anything that isn’t written into the page’s source code. [The study](/blog/lovable-tanstack-ssr-study) has the full numbers, how we collected them, and their limits.

### Predicting your own side in thirty seconds[#](#predicting-your-own-side-in-thirty-seconds)

The study tells you what to expect. Check your own app anyway. Check it directly. If you have your code locally (via [GitHub sync](/blog/lovable-github-sync) or a [ZIP export](/blog/download-lovable-code)) four greps settle it:

```
# Run from your project root.
find src/routes -name '*.tsx' | wc -l                 # route files total
grep -rl 'loader:' src/routes | wc -l                 # ...of which fetch on the server
grep -rl 'integrations/supabase/client' src | wc -l   # files querying the DB from the browser
grep -rl 'useEffect' src/routes | wc -l               # routes doing work after mount
```

If line two returns `0` and line three returns anything above it, you already have your answer and the curl test below will only confirm it. Then open a route you want indexed and look for three things, in order: a `loader:` key on the route definition, a `head:` key (63% of route files in the study have none, which is a per-page `<title>` and meta description missing before you get anywhere near the data question), and any `supabase.from(...)` sitting inside a component rather than behind a server call.

## How to check your own site with curl[#](#how-to-check-your-own-site-with-curl)

Do not look for framework hydration markers, and do not test the homepage. Look for the words you want to rank for, on a route whose content comes out of your database. That test is correct on every stack and survives every framework upgrade.

```
# Use the same user agent for every step so the results are comparable.
# On the React + Vite stack no user agent shows what verified crawlers get.
UA='Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)'

# 0. The one that matters. Pick a contiguous phrase that isn't in your source code.
curl -s -A "$UA" https://yourapp.com/products/blue-widget > /tmp/page.html
grep -qiF 'Ceramic Pour-Over Kettle' /tmp/page.html \
  && echo 'server-rendered' || echo 'client-fetched: crawlers never see it'

# 1. Strip tags and read what a non-rendering crawler actually gets.
perl -0pe 's/<(script|style)\b.*?<\/\1>//gsi' /tmp/page.html \
  | perl -0pe 's/<[^>]+>/ /g' | tr -s ' \n' ' ' | head -c 1200

# 2. Compare byte weight of the visible text across two routes.
# A page whose content arrives after hydration is noticeably small.
for r in / /products/blue-widget /blog; do
  n=$(curl -s -A "$UA" "https://yourapp.com$r" \
      | perl -0pe 's/<(script|style)\b.*?<\/\1>//gsi' \
      | perl -0pe 's/<[^>]+>/ /g' | tr -s ' \n' ' ' | wc -c)
  printf '%-28s %s bytes of text\n' "$r" "$n"
done

# 3. Confirm per-route metadata is real, not one baked set for the whole app.
for r in / /pricing /products/blue-widget; do
  echo "=== $r"
  curl -s -A "$UA" "https://yourapp.com$r" \
    | grep -oE '<(title>[^<]*|meta[^>]*og:(title|description)[^>]*|link[^>]*canonical[^>]*)>'
done

# 4. Is anything caching this? Read the headers before you assume.
curl -sI -A "$UA" https://yourapp.com/products/blue-widget \
  | grep -iE 'cf-cache-status|^age:|cache-control'
```

Step 2 is the one that catches the subtle case. On a route fed by loaders, a content page carries several kilobytes of extractable text. A page whose body arrives after hydration carries a few hundred bytes (navigation, footer, a heading) and looks fine to anything that only checks the status code.

Five ways this test lies to you

1.  **Strip `<script>` and `<style>` before counting words**, or you are counting inline CSS and a JSON payload. Steps 1 and 2 do this; a naive `wc -w` on raw HTML does not.
2.  **The search phrase must be one contiguous text run.** Markup splits phrases across elements, so `grep` for “Ceramic Pour-Over Kettle” fails if the page renders it as a `<span>` per word. Pick a phrase you can see is rendered as a single text node.
3.  **On Lovable’s React + Vite stack, this can’t be verified.** It returns the SPA shell whatever user agent you send, and what verified crawlers receive can’t be verified from outside. A failure there tells you nothing. Use Search Console’s URL Inspection for that stack.
4.  **Identical bytes across two requests is ambiguous**: it could mean cached, prerendered, or just deterministic output. Step 4 disambiguates: read `cf-cache-status` and `age`.
5.  **A hit inside a hidden streaming container is not a win.** If your phrase only appears inside a `<div hidden id="S:0">` block near the end of the document, it reached the bytes via streaming and needs a script run to land on the page.

If you are on the older React + Vite stack, run step 0 twice: once plain, once with the Googlebot user agent. Divergence there is expected and correct, because Lovable’s pre-rendering fires only for verified crawlers:

```
curl -s https://yourapp.lovable.app/ | grep -c '<h1'
curl -s -A 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)' \
  https://yourapp.lovable.app/ | grep -c '<h1'
```

If the Googlebot request returns content and the plain one doesn’t, you are still on the old stack: whatever you were told.

## What the plugin gives you, once you build it yourself[#](#what-the-plugin-gives-you-once-you-build-it-yourself)

None of the settings in this section can be changed 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. They only matter once you export the code and build it on your own machine or CI.

Two defaults to hold in your head before any of this: **SSR is on** (`ssr: true`) and **prerendering is off** (`prerender: { enabled: false }`). Everything below is a deviation from that, and one of the deviations looks like an SEO feature and isn’t.

**SPA mode** prerenders only the root route, renders the router’s pending fallback component where matched routes would go, writes the result to `/_shell.html`, and sets up 404-to-shell rewrites. Defaults: `outputPath` `/_shell.html`, `crawlLinks` false, `retryCount` 0.

```
// vite.config.ts: SPA mode. This is the cheap-static-hosting option.
// A crawler gets 200 + real HTML + your loading spinner. Not your content.
import { defineConfig } from 'vite'
import { tanstackStart } from '@tanstack/react-start/plugin/vite'

export default defineConfig({
  plugins: [tanstackStart({ spa: { enabled: true } })],
})
```

Read that carefully: a crawler requesting any route receives a valid 200 HTML document whose visible body is a loading state. Status-code checkers pass. Uptime monitors pass. A “does it return HTML” test passes. And the page has no content in it. It is the same trap as a loader-less SSR route, arrived at from the other direction.

**Static prerendering** is the actual SEO option in the same plugin, for builds you run yourself.

```
// vite.config.ts: build-time prerendering. Defaults shown explicitly.
export default defineConfig({
  plugins: [
    tanstackStart({
      prerender: {
        enabled: true,          // default false
        crawlLinks: true,       // default true
        concurrency: 14,        // default 14
        retryCount: 2,          // default 2
        failOnError: true,      // default true
        filter: ({ path }) => !path.startsWith('/app'),
      },
      pages: [
        { path: '/blog/hello-world', prerender: { enabled: true, outputPath: '/blog/hello-world/index.html' } },
      ],
    }),
  ],
})
```

Auto-discovery excludes routes with path params such as `/users/$userId`, underscore-prefixed layout routes, and component-less API routes. Param routes still get prerendered if `crawlLinks` reaches them through a real anchor, so if a route is only reachable by typed URL or by a client-side navigation, list it explicitly in `pages` or it silently won’t exist.

Prerendering runs your loaders at build time. It does not run your component effects, so it inherits the same boundary: whatever the loader returned is in the file, and whatever a hook fetches still isn’t.

**Selective SSR** is the third shape: per route, you choose whether `beforeLoad` and the loader run server-side and whether the component server-renders. That is the knob for keeping a private app section off the server while your marketing and content routes render fully.

### The ISR that isn’t[#](#the-isr-that-isnt)

If you go looking for incremental static regeneration in TanStack Start, stop looking. There is no ISR feature. No `isr`, `swr`, `revalidate` or `staleMaxAge` key exists in the plugin’s config schema. What the documentation files under that heading is build-time prerendering plus per-route `Cache-Control` headers that you write by hand: opt-in, worth doing, and not a framework mode you switch on.

One warning if you read that guide: its example passes a `routes` array inside `prerender`. That is not a real config key and the page has a bug. Use `pages`, as in the block above.

The default deployment has no caching layer either. On the official Cloudflare Workers setup with stock config, the HTML is composed fresh in the Worker on every request: nothing cached, nothing revalidated. You can confirm the behavior on TanStack’s own site: two requests two seconds apart come back with different bytes. On Lovable you can’t add those headers, so this only applies to builds you host yourself.

Self-hosting throws SSR away by accident

SSR needs a server runtime. If you export a TanStack Start project, run a plain build and drop the output on a static host, you have shipped the client bundle and nothing that renders it, which is functionally SPA mode without having asked for it. Deploy it as a Worker or a Node server, or switch deliberately to static prerendering. [Self-hosting a Lovable app](/blog/self-host-lovable-app) covers what else stops travelling with the code.

## Deciding by where the fetch lives[#](#deciding-by-where-the-fetch-lives)

The useful question is where each piece of indexable content is fetched. The study’s numbers let you guess your row before you open anything: the last column is how often each pattern showed up.

Where your indexable content is fetched

What SSR alone delivers

What to do

Share of routes in the study

Route `loader` or `createServerFn` called from one

Content is in the HTML

Nothing. This is the finished state.

4.4%, and 2.3% in recent repos

`useEffect` + `setState`, and you can refactor it

Shell only, content absent

Move the fetch into the loader. One prompt, zero dollars.

Most of the 44.6% that fetch in-component

`useQuery`, never prefetched or dehydrated

Shell only, content absent

Prefetch and dehydrate in the loader, or move it.

Same bucket; only 1 repo in 78 wired react-query into SSR

Resolved inside `<Suspense>` and streamed

In the bytes, in a hidden container

Check what a non-executing parser sees before calling it fixed.

Not measured

Third-party widget that injects itself: review embed, booking iframe

Never in the HTML, and you cannot move it

Pre-render layer, on top of SSR.

Not measured

Enumerable marketing routes, infrequent updates

Works, but recomputed per request

Build-time static prerendering. It is free and it is in the plugin.

52% are static JSX with no fetch at all

High-cardinality or per-user routes

Correct as-is

Leave them as they are.

n/a

Read the loader row and the static-JSX row together, because they are the two that decide how much work you have. Half of a typical Lovable app is static JSX and was never at risk: those routes were fine on the old stack and are fine now. The trouble is concentrated in the other half, and only 4.4% of page routes went the loader way. If your app is mostly marketing copy with a contact form, the study predicts you have close to nothing to fix. If your indexable pages load their content dynamically (a blog, a directory, a catalog, a location list) odds are the content is coming from a hook, and the `blogs` table showing up in 14 files of browser-side queries is that exact case caught in the act.

Ask, because the study only measured what you get unasked

None of this means Lovable can’t write a loader. The study only shows what it writes when nobody asks for one. So ask: “move the data fetch for this route into a TanStack Start route `loader` and read it with `Route.useLoaderData()`” is one prompt, and it is the whole distance between the first and second rows above. Start with the route holding the text you want to rank for, then re-run step 0 of the curl block to confirm it landed.

Hosting changes what is already handled for you. The table above applies on every host, because where the fetch lives is the same question everywhere.

Hosting situation

Already handled

Not handled

New TanStack Start project on Lovable hosting

Per-request SSR of the component tree

Anything fetched outside a loader, which the study says is most dynamically loaded content; per-route `<title>` and meta only where the route file defines `head:`, and 63% of route files in the study define none

Old React + Vite on Lovable hosting

Pre-rendered HTML for verified crawlers

Third-party scanners; anything fetched outside a loader

Either stack, self-hosted after export

Whatever you deploy yourself

Lovable’s crawler pre-rendering does not travel with the code

Any of the above, and you want crawl evidence

Nothing

Which bot got HTML, and which got a shell

Two rows deserve expanding.

**“You can refactor it” is doing a lot of work.** Most of the time the answer to content that arrives after hydration is to move the fetch into the loader, which costs one prompt and zero dollars. Reach for a pre-render layer only when you genuinely cannot: a vendor review widget that injects itself, a third-party booking iframe’s fallback content, an editor that refuses to server-render.

**A pre-render layer works on top of SSR.** It renders your page in a headless browser, waits for the client-side work to finish, and serves the complete HTML to crawlers. That fills the gap SSR leaves: content that arrives after hydration. A managed layer adds more: Markdown versions of your pages for AI agents, SEO fixes on each snapshot, crawl logs, and snapshots that refresh when your content changes. Google’s docs call dynamic rendering a workaround because of the extra complexity and resources it takes to run yourself, and its spam policies don’t treat it as a violation. That complexity is the part a managed service takes off your hands. Bing recommends it.

### If you’re building the pre-render layer yourself[#](#if-youre-building-the-pre-render-layer-yourself)

The manual path is entirely viable and worth understanding before you buy anything:

1.  **Put a Worker or middleware in front of your origin.** Match the request user agent against a crawler list. The open-source `prerender-node` middleware is the reference implementation and its source is a better spec than most articles.
2.  **Guard it properly.** Bail if there’s no user agent; bail unless the method is GET or HEAD; bail on static asset extensions (rendering your own JS bundle is how you burn a render quota in an afternoon); and carry a recursion-guard header, because your renderer fetching your own origin will otherwise loop forever.
3.  **Render and cache.** Either proxy to a hosted renderer, or drive Cloudflare’s Browser Rendering yourself. Cloudflare ships no first-party dynamic-rendering product, so this is hand-written either way.
4.  **Wait for the right signal.** A headless render that captures on `load` misses exactly the fetches you built this for. [Wait for the network to settle](https://encited.com/docs/troubleshooting/prerender-ready-signal), or for a selector that only exists once your data has arrived.
5.  **Fail open.** A dead renderer must never return 5xx to a crawler: fall through to the origin.
6.  **Invalidate on deploy** ([how cache refresh works](https://encited.com/docs/troubleshooting/understanding-cache-refresh-rules-in-encited)). This is the step teams skip, and it is the one that turns a compliant setup into a divergent one. Treat recache TTL as a correctness requirement.

There is a full working Cloudflare Worker for this in [SSR vs SPA for SEO](/blog/ssr-vs-spa-seo), and the Lovable-specific version in [the Lovable pre-rendering guide](/blog/lovable-prerendering).

Hosted services do the same thing if you’d rather not build it. [Encited](https://encited.com) renders in a headless browser, waits for the network to settle so component-hook fetches resolve, caches the result at the edge, and serves HTML to search bots and Markdown to AI agents on top of your existing SSR. Its [crawl log](https://encited.com/docs/crawl-logs/read-your-crawl-log) records which bot hit which URL and whether it got the snapshot, which is how you confirm GPTBot received your reviews.

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

1.  Run the four greps over your own `src/routes`. If nothing matches `loader:`, you already know the answer and everything below is confirmation and repair.
2.  Pick a phrase that isn’t in your source code and run step 0 of the curl block against the route that renders it. That single grep answers the question this post is about.
3.  If it came back empty, open that route file. Every `useEffect` that fetches, and every `useQuery` without a loader prefetch, is a candidate to move. Start with the one holding the text you want to rank for.
4.  If the phrase turned up only inside a hidden streaming container, treat that as a maybe rather than a yes, and check whether the consumers you care about run scripts.
5.  If you’re still on React + Vite and the upgrade’s preserved list covers what you care about, run it, then retest every route that imports a browser-only library, because that is the documented failure mode. Then rerun step 0, because the upgrade moves your rendering and not your fetches.
6.  If you export the code and build it yourself, check the build isn’t in SPA mode before you ship it.
7.  Only after all of that, decide whether a pre-render layer is buying you anything. If the answer is “it renders the widget we can’t control”, buy it. If the answer is “it makes SEO work”, you have not found your actual problem yet.

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)
-   [SSR only includes data that a loader fetched](#ssr-only-includes-data-that-a-loader-fetched)
-   [Which crawlers this actually costs you](#which-crawlers-this-actually-costs-you)
-   [Example 1: the product page whose reviews load after mount](#example-1-the-product-page-whose-reviews-load-after-mount)
-   [Example 2: the dashboard list nobody meant to index](#example-2-the-dashboard-list-nobody-meant-to-index)
-   [The streaming case: “it’s in the bytes” is not “it’s on the page”](#the-streaming-case-its-in-the-bytes-is-not-its-on-the-page)
-   [What the TanStack Start switch actually changed](#what-the-tanstack-start-switch-actually-changed)
-   [The upgrade path for existing projects](#the-upgrade-path-for-existing-projects)
-   [Which side does the generated code actually land on?](#which-side-does-the-generated-code-actually-land-on)
-   [Predicting your own side in thirty seconds](#predicting-your-own-side-in-thirty-seconds)
-   [How to check your own site with curl](#how-to-check-your-own-site-with-curl)
-   [What the plugin gives you, once you build it yourself](#what-the-plugin-gives-you-once-you-build-it-yourself)
-   [The ISR that isn’t](#the-isr-that-isnt)
-   [Deciding by where the fetch lives](#deciding-by-where-the-fetch-lives)
-   [If you’re building the pre-render layer yourself](#if-youre-building-the-pre-render-layer-yourself)
-   [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

Does TanStack Start SSR mean crawlers see everything on my page?

No. Server-side rendering renders your components, and it only includes data that a route loader fetched. The response contains your components in their initial state plus whatever the route loader returned and serialized into the document. React's own documentation notes that effects do not run during server rendering, so anything a component fetches after it mounts happens in a browser after the HTTP response has already closed. A crawler that reads the response bytes and stops never sees that content.

Does TanStack Start support ISR?

No. TanStack Start has no ISR feature: no isr, swr, revalidate or staleMaxAge key exists in the plugin's configuration schema. What the documentation files under that heading is build-time prerendering combined with per-route Cache-Control headers you write yourself. On the official Cloudflare Workers setup with default config nothing is cached or revalidated: the HTML is composed fresh in the Worker on every request.

How much does upgrading a Lovable project to TanStack Start cost?

The upgrade costs credits. It requires the older React + Vite stack and edit permissions, only one upgrade can run per project at a time, and it is fully reversible from version history without affecting your published site.

Is TanStack Start stable enough to run in production?

As of September 2026 the public position is a v1.0 Release Candidate, announced on 23 September 2025, with the API described as feature-complete and stable but the build acknowledged as not bug-free. A general-availability 1.0 could not be verified against tanstack.com. Lovable ships it as the default stack for new projects regardless, which is its own kind of production signal.

Does TanStack Start's SPA mode help SEO?

No, and it can actively mislead you. SPA mode prerenders only the root route into a shell file and renders the router's pending fallback where matched routes would go. A crawler gets a 200 response containing real HTML (which passes a naive check) but the body holds a loading state rather than your content. It's only a setting you'd meet in a build you run yourself; you can't switch it on in a Lovable project.

Does Lovable's generated TanStack Start code fetch data in route loaders?

Usually not. In a September 2026 study of 82 Lovable apps on GitHub, only 4.4% of page routes loaded their data in a route loader, and fewer still in apps built since June. Most fetched it inside the component with useEffect or useQuery, and 48 of 78 apps queried Supabase directly from the browser.

Do I still need a pre-rendering layer if I already have SSR?

Only if content that matters reaches the page after hydration and you cannot move it into a server-side loader. Pre-rendering runs the page in a headless browser, waits for the client-side fetches to finish, and caches the resulting HTML for crawlers. Even when your content already comes from loaders, a pre-render layer adds what SSR doesn't: a Markdown version of each page for AI agents, SEO fixes on every snapshot, and logs of what each bot received.

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.
    
-   ### [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.
    
-   ### [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.
    
-   ### [Lovable SEO: the complete 2026 guide](/blog/lovable-seo)
    
    Lovable SEO splits across two stacks, and SSR alone does not put your data in the HTML. Test both on your own routes, then close the gaps Lovable leaves to you.
    

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)