Diagram showing SEO pre-rendering workflow for a Lovable Vite + React app, generating static HTML for search engines and hydrating on the client.
Tail-Wagging Websites Blog
3

How I Actually Did SEO Pre-Rendering for a Lovable Vite + React App (No Lovable CLI Needed)

•
7 min read

When You Need Real SEO (and Lovable Can’t Do It) “If you’re using Lovable, you’ll hit this problem: Lovable can publish your app, but it can’t run the extra build steps you need for pre‑rendering. So if you want real SEO – meaning actual HTML with <title> and meta tags in the page source – you have to run a pre‑render build yourself in VS Code and deploy the output reddit.com . The author then lays out a step‑by‑step process: clone your Lovable project from GitHub, install react‑helmet‑async for SSR‑safe head tags, create a minimalist SeoHelmet component for your pages, and then add this SEO component at the top of each page reddit.com . They emphasise that you’re turning your React SPA into a set of static HTML files for key pages while still hydrating the app on load – this is build‑time pre‑rendering, not Next.js or a server reddit.com . A critical step is moving BrowserRouter out of App.tsx so the router can switch depending on whether you’re pre‑rendering or hydrating reddit.com . Finally, you create separate client and server entry points: the client hydrates the pre‑rendered HTML, and the server renders routes to HTML for deployment reddit.com . Once these steps are complete, your Lovable app delivers real, crawlable HTML without relying on the Lovable CLI.”

Here’s the full post, all in one place so you can copy/save it:


If you’re using Lovable, you’ll hit this problem:

Lovable can publish your app, but it can’t run the extra build steps you need for pre-rendering.

So if you want real SEO (real HTML, real <title>, real meta tags in “View Source”), you do the pre-render build in VS Code terminal and deploy the output.

This post is the exact step-by-step.

No assumptions.

⸻

What you’re building

You are turning a normal React SPA into:

  • Static HTML files for your key pages (home, services, pricing, blog, etc.)
  • with real head tags and real body content
  • but still keeping React as a SPA after load (it hydrates)

This is not Next.js.

This is not a server.

This is build-time pre-rendering.

⸻

Before you start (what you must have)

You need:

  1. Your Lovable project in a GitHub repo
  2. VS Code installed
  3. Node.js installed (so npm works)
  4. A terminal you can run commands in (VS Code terminal is fine)

⸻

Step 1 – Get the Lovable project into VS Code

  1. In Lovable, connect your project to GitHub
  2. In VS Code terminal:
git clone YOUR_GITHUB_REPO_URL
cd YOUR_REPO_FOLDER
npm install
npm run dev
  1. Open the local dev URL and confirm it works

If the site doesn’t run locally, stop and fix that first.

⸻

Step 2 – Install SSR-safe head tags

This is what makes SEO tags collectable during rendering.

Run:

npm i react-helmet-async

⸻

Step 3 – Create the SEO component every page will use

Create:

src/components/SeoHelmet.tsx

Use this exact minimal version:

import React from "react";
import { Helmet } from "react-helmet-async";

type Props = {
  title: string;
  description: string;
  path: string;
};

export function SeoHelmet({ title, description, path }: Props) {
  const url = `https://YOURDOMAIN.COM${path}`;

  return (
    <Helmet>
      <title>{title}</title>

      <meta name="description" content={description} />
      <link rel="canonical" href={url} />

      <meta property="og:title" content={title} />
      <meta property="og:description" content={description} />
      <meta property="og:url" content={url} />
      <meta property="og:type" content="website" />

      <meta name="twitter:card" content="summary_large_image" />
      <meta name="twitter:title" content={title} />
      <meta name="twitter:description" content={description} />
    </Helmet>
  );
}

Important:

  • Replace https://YOURDOMAIN.COM with your real domain.

⸻

Step 4 – Add SEO to each important page

On every key page component (home, services, pricing, etc.) add this at the top:

import { SeoHelmet } from "@/components/SeoHelmet";

export default function ServicesPage() {
  return (
    <>
      <SeoHelmet
        title="Services"
        description="What we offer…"
        path="/services"
      />

      <main>
        <h1>Services</h1>
      </main>
    </>
  );
}

If a page doesn’t include SeoHelmet, it will not get correct pre-rendered tags.

⸻

Step 5 – Move BrowserRouter OUT of App.tsx (this is critical)

Pre-rendering needs the router to switch depending on environment.

You must do this:

  • App.tsx should contain your <Routes> and <Route> only
  • It should NOT wrap with <BrowserRouter>

Example src/App.tsx:

import React from "react";
import { Routes, Route } from "react-router-dom";
import Home from "./pages/Home";
import Services from "./pages/Services";

export default function App() {
  return (
    <Routes>
      <Route path="/" element={<Home />} />
      <Route path="/services" element={<Services />} />
    </Routes>
  );
}

This step is what makes SSR possible.

⸻

Step 6 – Create the client entry (hydrates the HTML)

Create:

src/entry-client.tsx

import React from "react";
import { hydrateRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { HelmetProvider } from "react-helmet-async";
import App from "./App";

hydrateRoot(
  document.getElementById("root")!,
  <React.StrictMode>
    <HelmetProvider>
      <BrowserRouter>
        <App />
      </BrowserRouter>
    </HelmetProvider>
  </React.StrictMode>
);

This makes the pre-rendered HTML become interactive.

⸻

Step 7 – Create the server entry (renders routes to HTML)

Create:

src/entry-server.tsx

import React from "react";
import { renderToString } from "react-dom/server";
import { StaticRouter } from "react-router-dom/server";
import { HelmetProvider } from "react-helmet-async";
import App from "./App";

export function render(url: string) {
  const helmetContext: any = {};

  const html = renderToString(
    <HelmetProvider context={helmetContext}>
      <StaticRouter location={url}>
        <App />
      </StaticRouter>
    </HelmetProvider>
  );

  return { html, helmet: helmetContext.helmet };
}

This file is used only during the build.

⸻

Step 8 – Update index.html with injection placeholders

Open index.html and make sure your root looks like this:

<div id="root"><!--app-html--></div>

And in the <head>, add placeholders:

<!--helmet-title-->
<!--helmet-meta-->
<!--helmet-link-->
<!--helmet-script-->

Your final <head> area should contain those comments.

⸻

Step 9 – Create the prerender script

Create:

scripts/prerender.js

import fs from "fs";
import path from "path";
import { render } from "../dist-ssr/entry-server.js";

const template = fs.readFileSync("dist/index.html", "utf-8");

const routes = [
  "/",
  "/services",
  "/pricing",
  "/blog"
];

for (const route of routes) {
  const { html, helmet } = render(route);

  const out = template
    .replace("<!--app-html-->", html)
    .replace("<!--helmet-title-->", helmet.title.toString())
    .replace("<!--helmet-meta-->", helmet.meta.toString())
    .replace("<!--helmet-link-->", helmet.link.toString())
    .replace("<!--helmet-script-->", helmet.script.toString());

  const folder = path.join("dist", route === "/" ? "" : route);
  fs.mkdirSync(folder, { recursive: true });
  fs.writeFileSync(path.join(folder, "index.html"), out);

  console.log("✅ Pre-rendered:", route);
}

Now add your real routes to that list.

Example: /service-areas/northampton

⸻

Step 10 – Build in VS Code terminal (this is the Lovable workaround)

Because Lovable can’t run multiple build steps, you do it locally.

You need two builds:

  1. client build → dist/
  2. server build → dist-ssr/

then run prerender.

Run these commands:

npx vite build --outDir dist
npx vite build --ssr src/entry-server.tsx --outDir dist-ssr
node scripts/prerender.js

When it finishes, you should see:

  • dist/services/index.html
  • dist/pricing/index.html
  • etc.

⸻

Step 11 – Verify properly (do not use DevTools)

Open the built file directly:

cat dist/services/index.html

You must see:

  • <title>…</title>
  • <meta name="description" …>
  • and real page HTML inside <div id="root">

After deploy, confirm again by:

  • Right click page → View Page Source
  • Search for <title> and <meta name="description">

If it’s present in source, bots see it.

⸻

Step 12 – Deploy only the dist folder

This is the output you deploy.

Cloudflare Pages option

  • Use Cloudflare Pages to deploy the repo, or
  • Push dist/ into a separate deploy branch if you prefer

The important rule:

The deployed site must be serving the HTML files inside dist/.

⸻

Common failure points (read these if it “doesn’t work”)

  1. You left BrowserRouter inside App.tsx

    • Fix: Router provider belongs in entry files
  2. You didn’t add the route to routes[] in prerender.js

    • Fix: only listed routes get HTML files
  3. You checked DevTools instead of View Source

    • Fix: View Source or cat dist/...
  4. Your page doesn’t render SeoHelmet

    • Fix: add it to every important page

⸻

What you get when this is done

  • SEO tags visible in raw HTML
  • Real content for crawlers
  • Better social previews
  • Still behaves like a SPA after load

One important thing before you copy this into production

If you follow the steps above as-is, you’ll get working pre-rendered HTML.

However, there’s one decision point I deliberately haven’t fully automated in this post, because it’s the place where people usually break their app without realising it.

Specifically:

  • Which routes are safe to pre-render
  • Which routes must stay client-only
  • How auth, dashboards, and dynamic data affect pre-rendering
  • How to avoid accidentally shipping cached HTML for user-specific pages

This part is not one-size-fits-all.

Two apps can follow the same steps above and still need different route strategies, depending on:

  • whether you have auth
  • whether pages rely on cookies/session
  • whether Lovable generated shared layouts
  • whether you’re planning to add users later

If you guess here, things often look fine locally and then quietly break in production.


If you want help doing this safely

If you’d like, I can:

  • review your route list
  • tell you exactly which pages should be pre-rendered vs left SPA-only
  • sanity-check your setup before you deploy
  • help you avoid SEO or auth issues you won’t see until later

Just reply with:

  • your route list
  • whether your app has auth / dashboards
  • and where you’re deploying

I usually do this as a short, focused review so you don’t lose days debugging edge cases — but I’m happy to point you in the right direction either way.

Get Personalized Help

Ready to implement these strategies? Book a free consultation to discuss your specific needs, or explore our complete pet-care web design services for comprehensive solutions.

Related posts