---
title: "Per-page titles and meta descriptions in Lovable | Lovable Field Guide"
description: "Every route of a client-rendered app ships the same title. How to set Lovable meta tags per route on TanStack Start and Vite, and where the built-in checks stop."
lang: en
json-ld: |
  {
    "@context": "https://schema.org",
    "@graph": [
      {
        "@type": "WebSite",
        "@id": "https://prerenderlovable.com/#website",
        "url": "https://prerenderlovable.com",
        "name": "The Lovable Field Guide",
        "description": "A working journal on Lovable SEO: getting apps built with Lovable indexed, ranked and cited, plus the migration and integration work that comes with it.",
        "publisher": {
          "@id": "https://prerenderlovable.com/#organization"
        },
        "inLanguage": "en"
      },
      {
        "@type": "Organization",
        "@id": "https://prerenderlovable.com/#organization",
        "name": "The Lovable Field Guide",
        "url": "https://prerenderlovable.com"
      },
      {
        "@type": "BlogPosting",
        "@id": "https://prerenderlovable.com/blog/lovable-meta-tags-and-titles#article",
        "headline": "Per-page titles and meta descriptions in Lovable",
        "description": "Every route of a client-rendered app ships the same title. How to set Lovable meta tags per route on TanStack Start and Vite, and where the built-in checks stop.",
        "datePublished": "2026-09-11T00:00:00.000Z",
        "dateModified": "2026-09-11T00:00:00.000Z",
        "inLanguage": "en",
        "mainEntityOfPage": {
          "@type": "WebPage",
          "@id": "https://prerenderlovable.com/blog/lovable-meta-tags-and-titles"
        },
        "author": {
          "@type": "Organization",
          "name": "The Lovable Field Guide",
          "url": "https://prerenderlovable.com"
        },
        "publisher": {
          "@id": "https://prerenderlovable.com/#organization"
        },
        "keywords": "lovable meta tags, lovable page title, lovable meta description, duplicate titles lovable, lovable per-route metadata",
        "articleSection": "SEO"
      },
      {
        "@type": "BreadcrumbList",
        "@id": "https://prerenderlovable.com/blog/lovable-meta-tags-and-titles#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": "Per-page titles and meta descriptions in Lovable",
            "item": "https://prerenderlovable.com/blog/lovable-meta-tags-and-titles"
          }
        ]
      },
      {
        "@type": "FAQPage",
        "@id": "https://prerenderlovable.com/blog/lovable-meta-tags-and-titles#faq",
        "mainEntity": [
          {
            "@type": "Question",
            "name": "Why does every page of my Lovable site have the same title?",
            "acceptedAnswer": {
              "@type": "Answer",
              "text": "On a client-rendered React + Vite build, the only title in the HTTP response is the one baked into index.html. Libraries like react-helmet change the title by mutating document.head after the page loads, which happens long after the response body has closed. Anything that reads the raw HTML (most crawlers and every link unfurler) sees the baked-in title instead."
            }
          },
          {
            "@type": "Question",
            "name": "Does Google see the titles my React app sets with JavaScript?",
            "acceptedAnswer": {
              "@type": "Answer",
              "text": "Usually yes. Googlebot renders pages with an evergreen Chromium, and measurements by Vercel and MERJ put the median render delay at about 10 seconds. The problem is everything else: link unfurlers, most AI crawlers (Vercel and MERJ measured GPTBot, ClaudeBot and PerplexityBot running no JavaScript in December 2024) and Bing, which testing by Screaming Frog found tends to use the title from the initial HTML even when it renders the body."
            }
          },
          {
            "@type": "Question",
            "name": "How long should a title tag and meta description be?",
            "acceptedAnswer": {
              "@type": "Answer",
              "text": "Google truncates search snippets by pixel width and has never published a limit. As a working budget, keep titles near 50 to 60 characters and descriptions near 150 to 160, put the distinguishing words first, and treat anything past that as likely to be cut."
            }
          },
          {
            "@type": "Question",
            "name": "Does Lovable set per-page metadata automatically?",
            "acceptedAnswer": {
              "@type": "Answer",
              "text": "Lovable's documentation says metadata is auto-generated and that its SEO review checks for duplicate titles, placeholder text, weak descriptions and wrong canonicals, with og:title, og:description and og:image settable per route. It grades findings rather than guaranteeing them, so verify the published HTML yourself."
            }
          },
          {
            "@type": "Question",
            "name": "Does upgrading to TanStack Start fix duplicate titles by itself?",
            "acceptedAnswer": {
              "@type": "Answer",
              "text": "It fixes the static case. Metadata you type into a route file is resolved on the server and lands in the HTML response. Titles built from fetched data are separate: server rendering doesn't run effects, so a title assembled after hydration is still absent from the response body. Move that data into a route loader or a server function."
            }
          },
          {
            "@type": "Question",
            "name": "Do I have to republish after editing my metadata in Lovable?",
            "acceptedAnswer": {
              "@type": "Answer",
              "text": "Yes. Publishing is snapshot-based, so edits made after a publish do not reach the live site until you publish again. Always run your checks against the live domain."
            }
          }
        ]
      }
    ]
  }
---

[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.  /  Per-page titles and meta descriptions in Lovable 

# Per-page titles and meta descriptions in Lovable

Every route of a client-rendered app ships the same title. How to set Lovable meta tags per route on TanStack Start and Vite, and where the built-in checks stop.

Published September 11, 2026 · 12 min read 

A client-rendered single-page app has exactly one `<title>` in its HTTP response | the one baked into `index.html`, and it serves that same title, description and canonical for every route. Whether your Lovable project has this problem depends entirely on which stack it is on, so establish that first, then fix the metadata at the layer crawlers actually read.

## First: which stack is your project on?[#](#first-which-stack-is-your-project-on)

On 13 May 2026 Lovable changed its default stack from React + Vite to TanStack Start with SSR on Cloudflare Workers. Existing projects were not migrated; Lovable’s blog states that every project already built continues to run exactly as before. So there are two populations, and the advice for them differs.

Check `package.json` rather than guessing:

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

-   `@tanstack/react-start` present → new stack. The server renders your route components, so every request gets real markup, crawlers included. That is markup, not necessarily your data; see below.
-   only `react-router-dom` → old stack. Client-rendered, with Lovable applying on-request pre-rendering on deployed public URLs, served only to _verified_ crawlers.

Do not read that first bullet as “metadata is handled”. Server rendering renders your components, and it only includes data that a route loader fetched. React’s `useEffect` documentation is blunt about it: “Effects only run on the client. They don’t run during server rendering”, and TanStack Query’s SSR guide says the same of queries you haven’t prefetched: they “wont be server rendered, instead they will be fetched on the client after the application is interactive.” A page can be 100% server-rendered and still ship `<title>Loading…</title>`, because the title was assembled from data the server never fetched. Lovable’s own blog wording is conditioned on exactly this: the server “runs the React tree, **executes the loaders**, and streams the result.” Loaders. Where the fetch lives decides what reaches the `<head>`, and the dynamic-routes section below is where that bites.

The second bullet matters for debugging, too. On an old-stack, Lovable-hosted project, Screaming Frog or Ahrefs will report your pages as empty or title-less. Lovable’s docs say verified crawlers get pre-rendered HTML instead, which can’t be verified from outside. If you want the wider picture of what changes between the two stacks, start with the [Lovable SEO overview](/blog/lovable-seo).

Lovable's own docs disagree with each other

As of September 2026 the deployment/hosting page still describes projects as “standard Vite + React”, and `llms.txt` says the same. The SEO, hosting and TanStack Start pages say new apps are SSR. Check `package.json`; it’s the only reliable source.

## Why one static title ends up on every route[#](#why-one-static-title-ends-up-on-every-route)

The mechanism is worth stating precisely, because the popular explanation (“Google can’t read React”) is wrong and has been for years.

1.  The server returns `index.html`. Its `<head>` contains whatever the build baked in: identical for every URL.
2.  A crawler reads that byte stream, extracts `title`, `og:*` and `twitter:*`, and is done.
3.  Only later, in a real browser, does the bundle download, React mount, and `react-helmet` imperatively mutate `document.head`.

A crawler that never executes step 3 cannot observe it. The library is not broken: react-helmet’s README documents calling `Helmet.renderStatic()` after `renderToString` on the server. `curl` of create-react-app.dev returns `<meta data-react-helmet="true" property="og:image" ...>` in the raw HTML, because Docusaurus renders helmet’s output at build time. Same library, opposite result, purely because the output landed in the response body.

Swapping to `react-helmet-async` changes nothing about this. It is the same client-side mutation with a different concurrency story.

## What identical titles actually cost you[#](#what-identical-titles-actually-cost-you)

Be honest about who is affected, because the blast radius is smaller than most posts claim and differently shaped.

Consumer

Sees JS-set titles?

Consequence of one static title

Googlebot

Yes: renders with evergreen Chromium; median render delay ~10s (p75 26s)

Mostly unaffected, but thin/duplicate initial HTML feeds “Crawled – currently not indexed” verdicts

Bingbot

Inconsistently. Bing says processing JS “at scale” is difficult

Screaming Frog’s testing found Bing tends to use the _initial-HTML_ title even when it renders the body, so every page is titled identically in Bing

Link unfurlers (Slack, Meta, LinkedIn, WhatsApp)

No: Slack’s own docs describe fetching a byte range, not rendering; Meta’s 1 MB fetch cutoff and LinkedIn’s tag requirements imply the same, though neither states it

Every shared URL previews as the same page. Covered in [why Lovable link previews break](/blog/lovable-link-previews-broken)

AI crawlers (GPTBot, ClaudeBot, PerplexityBot, Bytespider)

No, per Vercel/MERJ measurement (Dec 2024)

Your page is one undifferentiated blob of site-wide boilerplate

Applebot

Yes: Apple’s docs say it “may render the content of your website within a browser”

Unaffected

The Bing detail is the sharpest and least known: a SPA can rank in Bing on rendered body content while every result carries the same title text. You will not diagnose that in Google Search Console, which offers no duplicate-title list: only indirect signals like “[Crawled – currently not indexed](https://encited.com/blog/fix-discovered-crawled-currently-not-indexed-google-search-console)”. Duplicate titles surface in third-party crawls, in Lovable’s own SEO review, and in Bing Webmaster Tools.

Gotcha

Bot-challenge pages are their own metadata failure. A curl with a Facebook user agent against lovable.dev in September 2026 returned `<title>Just a moment...</title>`: Cloudflare’s interstitial. If your WAF challenges unfurlers, every shared link previews as “Just a moment…”, no matter how correct your tags are. The [OG image previewer](https://encited.com/free-tools/og-image-previewer) shows what a preview will look like.

## Verify before you fix[#](#verify-before-you-fix)

Run this against the live domain. It prints exactly what a non-rendering client receives.

```
for route in / /pricing /blog/hello-world; do
  echo "=== $route ==="
  curl -sL -A 'facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)' \
    "https://yourapp.com$route" \
    | grep -oE '<title>[^<]*</title>|<meta[^>]*(description|og:|twitter:)[^>]*>|<link[^>]*canonical[^>]*>'
done
```

If the three blocks are identical, you have the problem.

One caveat before you act on the output. Curling a Lovable-hosted Vite project from your laptop returns the SPA shell whatever user agent you send, so it tells you nothing about what Google or Slack received. Lovable’s docs say verified crawlers get pre-rendered HTML, and that can’t be verified from outside. For that stack, confirm with Search Console’s URL Inspection (“View crawled page”) and with each platform’s own debugger for unfurls. On the TanStack stack, and on anything you host yourself, curl is telling you the truth.

## Setting titles per route on TanStack Start[#](#setting-titles-per-route-on-tanstack-start)

TanStack Start resolves route metadata on the server and writes it into the HTML response. For static values (the strings you type into the route file) that closes the rendering gap completely. Values built from fetched data are a different problem, and the next section is about them. Each route passes a `head` function in its route options; the root document renders the collected tags.

```
// src/routes/pricing.tsx
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/pricing')({
  head: () => ({
    meta: [
      { title: 'Pricing | Acme' },
      { name: 'description', content: 'Per-seat pricing. No setup fees, no minimum.' },
      { property: 'og:type', content: 'website' },
      { property: 'og:url', content: 'https://acme.com/pricing' },
      { property: 'og:title', content: 'Pricing | Acme' },
      { property: 'og:description', content: 'Per-seat pricing. No setup fees, no minimum.' },
      { property: 'og:image', content: 'https://acme.com/og/pricing.png' },
      { name: 'twitter:card', content: 'summary_large_image' },
    ],
    links: [{ rel: 'canonical', href: 'https://acme.com/pricing' }],
  }),
  component: Pricing,
})
```

Two dependencies to check. First, the root route must render the head-output component (`HeadContent` in current TanStack Start) inside the document’s `<head>`, or none of this reaches the response. Second, TanStack Start was still labeled a Release Candidate on tanstack.com when this was written, and head-related export names moved during that period: confirm against the version in your `package.json` before assuming a snippet compiles.

Put site-wide defaults (`og:site_name`, the fallback `og:image`, `twitter:card`) on the root route and override only what differs per route. Keep the per-route list short; duplication across twenty route files is how descriptions go stale.

### Dynamic routes need the data on the server[#](#dynamic-routes-need-the-data-on-the-server)

This is the gap SSR does not close for you. The server renders the component tree and serializes whatever the server itself fetched; effects and un-prefetched queries are not part of that. TanStack Start’s own Query guide states the condition plainly: “The route awaits the query for content that must appear in the initial HTML.” Await it in the route, or it isn’t in the HTML. So if a blog post’s title is fetched by the component after hydration, the metadata in the response body is whatever your loading branch renders: exactly the failure you just migrated away from, now running on a server.

```
// src/routes/blog.$slug.tsx
import { createFileRoute } from '@tanstack/react-router'
import { getPost } from '../server/posts'   // a createServerFn, runs server-side

export const Route = createFileRoute('/blog/$slug')({
  loader: ({ params }) => getPost({ data: params.slug }),
  head: ({ loaderData }) => ({
    meta: [
      { title: `${loaderData.title} | Acme` },
      { name: 'description', content: loaderData.excerpt },
      { property: 'og:title', content: loaderData.title },
      { property: 'og:description', content: loaderData.excerpt },
      { property: 'og:image', content: loaderData.ogImage },
    ],
    links: [{ rel: 'canonical', href: `https://acme.com/blog/${loaderData.slug}` }],
  }),
  component: Post,
})
```

The rule: anything that feeds a meta tag has to come from a route loader or a server function, never from a `useEffect` or an un-prefetched `useQuery`. “SSR is on” and “my title is in the HTML” are independent facts, and only the second one is worth anything to a crawler.

Test it against a page with dynamically loaded content (CMS posts, product pages, directory listings: anything that isn’t written into the page’s source code): never the homepage, which is usually static and will pass no matter what your blog pages do:

```
curl -s -A 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)' \
  https://yourdomain.com/blog/hello-world > /tmp/page.html
grep -qiF 'a phrase that exists only in that post' /tmp/page.html \
  && echo 'server-rendered' || echo 'client-fetched: crawlers never see it'

# Then look at where it landed: this is the command caveat 2 refers to:
grep -oi '.\{120\}a phrase that exists only in that post.\{120\}' /tmp/page.html
```

Three things that make this test lie if you skip them:

1.  **The search phrase must be one contiguous text run.** Markup splits phrases across elements, so `<h1>Hello <em>World</em></h1>` will never match “Hello World”. Pick a phrase you know sits inside a single text node.
2.  **Check where the string landed.** React’s streaming SSR can deliver resolved content inside a `<div hidden id="S:0">…</div>` with a `$RC(...)` script that moves it into place after the fact. A title string parked in a hidden placeholder div is not a `<title>` in the `<head>`. Grep, then look at the surrounding bytes.
3.  **On the old Vite stack this test proves nothing**, for the verified-crawler reason above. Use Search Console there.

## Setting titles per route on React + Vite[#](#setting-titles-per-route-on-react--vite)

Two layers, and layer one alone is not enough.

**Layer one: per-route tags for browsers and Google’s render pass.** `react-helmet-async` in each route component is fine for this, and it is what keeps the browser tab and Google’s rendered snapshot correct.

**Layer two: get the tags into the response body.** Three options, in descending order of how much they actually solve:

1.  **Stay on Lovable hosting.** Lovable pre-renders deployed public URLs for verified crawlers: Google, Bing, social bots and AI engines.
2.  **Upgrade to TanStack Start.** Triggered from chat or project settings, it costs credits, is reversible from version history, and titles and descriptions are on the documented list of things it preserves. Expect two things: the documented risk is browser-only libraries breaking server rendering in ways the upgrade’s checks miss, so test after; and the upgrade moves your head tags to the server without moving your _data_ there. Routes whose titles come from a fetch still need a loader.
3.  **Stamp per-route HTML at build time.** If you have exported to GitHub and deployed elsewhere, Lovable’s pre-rendering does not come with you. A post-build script that writes one HTML file per route fixes titles, descriptions, canonicals and OG tags for every crawler at once:

```
// scripts/emit-route-html.mjs: run AFTER `vite build`
import { readFile, writeFile, mkdir } from 'node:fs/promises'
import { dirname, join } from 'node:path'

const SITE = 'https://acme.com'
const routes = [
  { path: '/',        title: 'Acme | Ship faster', desc: 'Build and deploy in minutes.' },
  { path: '/pricing', title: 'Pricing | Acme',     desc: 'Per-seat pricing. No setup fees.' },
]
const esc = (s) => s.replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]))
const shell = await readFile('dist/index.html', 'utf8')

for (const r of routes) {
  const url = new URL(r.path, SITE).href
  const head = `
    <title>${esc(r.title)}</title>
    <meta name="description" content="${esc(r.desc)}">
    <link rel="canonical" href="${url}">
    <meta property="og:url" content="${url}">
    <meta property="og:title" content="${esc(r.title)}">
    <meta property="og:description" content="${esc(r.desc)}">`

  const html = shell
    .replace(/<title>[\s\S]*?<\/title>/i, '')
    .replace(/<link\s+rel="canonical"[^>]*>/i, '')
    .replace('</head>', `${head}\n  </head>`)

  const out = r.path === '/' ? 'dist/index.html' : join('dist', r.path, 'index.html')
  await mkdir(dirname(out), { recursive: true })
  await writeFile(out, html)
}
```

The honest limit: this fixes the `<head>` only. The `<body>` is still an empty root div, so crawlers get correct titles and no content. That’s a real improvement for link previews and Bing titles, and the body content still needs rendering.

Delete the competing tags from index.html

Whichever layer-two route you take, strip the hardcoded `title`, `meta name="description"`, `link rel="canonical"` and any `og:*` tags from `index.html` first. A baked-in canonical is the worst of them: one homepage canonical served on every route tells Google to collapse your whole site into one URL, which is worse than having no canonical at all. See [canonical tags on Lovable](/blog/lovable-canonical-tags).

Gotcha

If your router is `HashRouter`, none of this works. Per RFC 3986 the fragment after `#` is never transmitted to the server, so no CDN, worker or pre-renderer can know which route was requested. Moving to `BrowserRouter` with a catch-all rewrite comes first; nothing else works without it.

## How long should titles and descriptions be?[#](#how-long-should-titles-and-descriptions-be)

Google truncates snippets by rendered pixel width and has never published a limit. A title of sixty capital Ms is far wider than sixty lowercase i’s, so treat any character count as a rough guide. Working budget:

-   **Title: 50–60 characters.** Distinguishing words first, brand last, one delimiter. `Pricing | Acme` beats `Acme | The all-in-one platform for teams | Pricing`.
-   **Description: 150–160 characters.** Write it to earn the click, the way you’d write an ad.
-   **Uniqueness matters more than length.** Two pages at 55 characters that read identically are worse than one at 72 that says something specific.

Google also generates the title link from several sources and may not use your `<title>` at all, and it rewrites meta descriptions for a large share of queries. Encited’s free [meta tag audit](https://encited.com/free-tools/meta-tag-audit) checks a page’s title, description and canonical in one pass. Bing, per the testing above, is the surface where your raw-HTML title most directly _is_ the displayed title.

## What Lovable’s built-in metadata handling covers[#](#what-lovables-built-in-metadata-handling-covers)

Per Lovable’s SEO documentation as of September 2026, metadata is auto-generated, and the “SEO & AI search review” checks for:

-   duplicate titles across routes
-   placeholder text left in tags
-   weak descriptions
-   wrong canonicals
-   per-route `og:title`, `og:description` and `og:image`
-   semantic HTML, alt text and indexing status

Findings are graded green (passing), blue lightbulb (low impact), amber (medium) and red X (high). Structured data gets its own pass: Lovable checks that JSON-LD is parseable and matches visible page content, and deliberately does not flag missing generic schema.

Where it stops:

-   **It grades, it does not guarantee.** The same docs hedge that `sitemap.xml` and `robots.txt` are “not always generated up front”. Treat metadata the same way and verify the published HTML.
-   **It has no view of your SERP.** No pixel-width check, no idea which title Google or Bing actually displayed, no CTR data. That comes from Search Console and Bing Webmaster Tools.
-   **Publishing is a snapshot.** Edits after a publish do not reach the live site until you publish again: the single most common reason a metadata fix appears not to have worked.
-   **Private projects are unindexable.** Lovable’s docs are explicit: only publicly published projects can be indexed, and branded workspace URLs cannot, regardless of SEO setup.
-   **Lovable’s docs disclaim page speed, mobile usability, content quality and backlinks.** The review is a technical metadata check and nothing more.

## Prompts that get this done in Lovable[#](#prompts-that-get-this-done-in-lovable)

Run them in order, and verify after each. The failure mode worth guarding against is an agent reporting success without the change landing.

**1\. Audit first, change nothing:**

> List every route in this project and, for each one, show me the exact `title`, `meta name="description"`, `link rel="canonical"` and `og:` tags that end up in the HTML response for that URL. Do not change any code yet. Tell me which routes share identical values.

**2\. Per-route metadata:**

> Give every route its own title and meta description, set in the route definition so they are present in the server response rather than applied by client-side JavaScript. Titles: 50–60 characters, page-specific words first, brand last. Descriptions: 150–160 characters, unique per page. Add matching `og:title`, `og:description` and an absolute `og:url` per route. Show me the diff for two routes first.

**3\. Remove the competing static tags:**

> Remove the hardcoded `title`, `meta name="description"`, `link rel="canonical"` and any `og:` tags from the document shell, keeping only genuinely site-wide defaults such as `og:site_name`, `twitter:card` and the fallback `og:image`. Per-route values must come from the routes.

**4\. Dynamic routes:**

> For routes with URL parameters, move the data those pages need into the route loader or a server function, and build the title and description from that loaded data. Nothing that feeds a meta tag should be fetched in a `useEffect`.

Then publish, and re-run the curl loop from earlier against the live domain. If the output still shows three identical blocks, the change did not land regardless of what the chat said.

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

1.  Run the `grep` on `package.json` and write down which stack you are on. Every decision above forks on it.
2.  Run the curl loop against three live URLs (including one with dynamically loaded content, such as a product or blog post page) and save the output. That is your before state. On a Lovable-hosted Vite project curl only sees the shell, and what verified crawlers receive can’t be verified from outside.
3.  Fix the routes with search traffic or shared links first: homepage, pricing, top blog posts. The rest can wait.
4.  Add Bing Webmaster Tools alongside Search Console. Bing is where a stale initial-HTML title is most likely to be the title real people see.
5.  Re-run the curl loop after publishing, and diff it against the before state.

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

-   [First: which stack is your project on?](#first-which-stack-is-your-project-on)
-   [Why one static title ends up on every route](#why-one-static-title-ends-up-on-every-route)
-   [What identical titles actually cost you](#what-identical-titles-actually-cost-you)
-   [Verify before you fix](#verify-before-you-fix)
-   [Setting titles per route on TanStack Start](#setting-titles-per-route-on-tanstack-start)
-   [Dynamic routes need the data on the server](#dynamic-routes-need-the-data-on-the-server)
-   [Setting titles per route on React + Vite](#setting-titles-per-route-on-react--vite)
-   [How long should titles and descriptions be?](#how-long-should-titles-and-descriptions-be)
-   [What Lovable’s built-in metadata handling covers](#what-lovables-built-in-metadata-handling-covers)
-   [Prompts that get this done in Lovable](#prompts-that-get-this-done-in-lovable)
-   [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

Why does every page of my Lovable site have the same title?

On a client-rendered React + Vite build, the only title in the HTTP response is the one baked into index.html. Libraries like react-helmet change the title by mutating document.head after the page loads, which happens long after the response body has closed. Anything that reads the raw HTML (most crawlers and every link unfurler) sees the baked-in title instead.

Does Google see the titles my React app sets with JavaScript?

Usually yes. Googlebot renders pages with an evergreen Chromium, and measurements by Vercel and MERJ put the median render delay at about 10 seconds. The problem is everything else: link unfurlers, most AI crawlers (Vercel and MERJ measured GPTBot, ClaudeBot and PerplexityBot running no JavaScript in December 2024) and Bing, which testing by Screaming Frog found tends to use the title from the initial HTML even when it renders the body.

How long should a title tag and meta description be?

Google truncates search snippets by pixel width and has never published a limit. As a working budget, keep titles near 50 to 60 characters and descriptions near 150 to 160, put the distinguishing words first, and treat anything past that as likely to be cut.

Does Lovable set per-page metadata automatically?

Lovable's documentation says metadata is auto-generated and that its SEO review checks for duplicate titles, placeholder text, weak descriptions and wrong canonicals, with og:title, og:description and og:image settable per route. It grades findings rather than guaranteeing them, so verify the published HTML yourself.

Does upgrading to TanStack Start fix duplicate titles by itself?

It fixes the static case. Metadata you type into a route file is resolved on the server and lands in the HTML response. Titles built from fetched data are separate: server rendering doesn't run effects, so a title assembled after hydration is still absent from the response body. Move that data into a route loader or a server function.

Do I have to republish after editing my metadata in Lovable?

Yes. Publishing is snapshot-based, so edits made after a publish do not reach the live site until you publish again. Always run your checks against the live domain.

Read next

## Keep going

-   ### [Fix broken link previews on a Lovable site](/blog/lovable-link-previews-broken)
    
    Lovable link preview not working? Unfurlers parse raw HTML, and in practice none of them run your JS. The mechanism, the fix per stack, and how to re-scrape.
    
-   ### [Canonical tags in Lovable: the rules and the traps](/blog/lovable-canonical-tags)
    
    Google's canonical rules, then the SPA traps that ruin a Lovable canonical tag: one baked URL on every route, trailing-slash forks, and lovable.app vs your domain.
    
-   ### [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)