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.
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.
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, 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: noisr,swr,revalidateorstaleMaxAgekey 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
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
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.
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
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 streaming case: “it’s in the bytes” is not “it’s 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
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.
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.
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 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 has the full numbers, how we collected them, and their limits.
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 or a ZIP export) 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
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.
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
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
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.
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.
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
The manual path is entirely viable and worth understanding before you buy anything:
- Put a Worker or middleware in front of your origin. Match the request user agent against a crawler list. The open-source
prerender-nodemiddleware is the reference implementation and its source is a better spec than most articles. - 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.
- 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.
- Wait for the right signal. A headless render that captures on
loadmisses exactly the fetches you built this for. Wait for the network to settle, or for a selector that only exists once your data has arrived. - Fail open. A dead renderer must never return 5xx to a crawler: fall through to the origin.
- Invalidate on deploy (how cache refresh works). 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, and the Lovable-specific version in the Lovable pre-rendering guide.
Hosted services do the same thing if you’d rather not build it. Encited 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 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
- Run the four greps over your own
src/routes. If nothing matchesloader:, you already know the answer and everything below is confirmation and repair. - 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.
- If it came back empty, open that route file. Every
useEffectthat fetches, and everyuseQuerywithout a loader prefetch, is a candidate to move. Start with the one holding the text you want to rank for. - 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.
- 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.
- If you export the code and build it yourself, check the build isn’t in SPA mode before you ship it.
- 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.
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
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
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
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
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.