{
  "slug": "Deploying-a-Lovable--TanStack-Start--App-with-GitHub-Actions---the-Real-Story-272he",
  "title": "Deploying a Lovable (TanStack Start) App with GitHub Actions — the Real Story",
  "subtitle": "",
  "excerpt": "Deploying a Lovable (TanStack Start) App with GitHub Actions — the Real Story Lovable is a great way to scaffold a TanStack Start app fast. But the moment you want to ship it yourself — on your own domain, through your…",
  "date": "2026-07-18",
  "tags": [
    "DevOps"
  ],
  "readingTime": "5 min",
  "url": "https://www.linkedin.com/pulse/deploying-lovable-tanstack-start-app-github-actions-real-shaterian-272he",
  "hero": "https://media.licdn.com/dms/image/v2/D4E12AQEWC5ssZzwE2w/article-cover_image-shrink_720_1280/B4EZ91qfToHwAQ-/0/1784385506734?e=2147483647&v=beta&t=2kuypZBRG9rN20NVTuoMQbQKZO4lDrHFSjxTeM8d1FY",
  "content": [
    {
      "type": "heading",
      "level": 2,
      "text": "Deploying a Lovable (TanStack Start) App with GitHub Actions — the Real Story"
    },
    {
      "type": "paragraph",
      "html": "Lovable is a great way to scaffold a TanStack Start app fast. But the moment you want to ship it yourself — on your own domain, through your own CI/CD — you run into a gap in the docs: <strong>Lovable's Vite config wrapper is built around Cloudflare by default</strong>, and most people don't actually want Cloudflare. They want a static export they can push anywhere, including plain old GitHub Pages.<!---->"
    },
    {
      "type": "paragraph",
      "html": "This article walks through exactly that: taking a Lovable-generated TanStack Start project and getting it building and deploying cleanly through GitHub Actions to GitHub Pages, including the two failure modes you'll almost certainly hit along the way.<!---->"
    },
    {
      "type": "heading",
      "level": 3,
      "text": "Where you start"
    },
    {
      "type": "paragraph",
      "html": "A typical Lovable vite.config.ts looks like this:<!---->"
    },
    {
      "type": "code",
      "lang": "nginx",
      "code": "import { defineConfig } from \"@lovable.dev/vite-tanstack-config\";\n\nexport default defineConfig({\n  tanstackStart: {\n    server: { entry: \"server\" },\n  },\n});"
    },
    {
      "type": "paragraph",
      "html": "That comment is doing a lot of quiet work: it's telling you Nitro is already wired up, targeting Cloudflare, and that you shouldn't add another nitro() plugin instance or you'll get duplicate-plugin errors. Fine — until you try to deploy somewhere that isn't Cloudflare.<!---->"
    },
    {
      "type": "heading",
      "level": 3,
      "text": "Failure #1: CI succeeds, but the site 404s"
    },
    {
      "type": "paragraph",
      "html": "A common GitHub Actions workflow for this kind of project looks like:<!---->"
    },
    {
      "type": "code",
      "lang": "typescript",
      "code": "name: Deploy TanStack Start app to GitHub Pages\n\non:\n  push:\n    branches: [main]\n  workflow_dispatch:\n\npermissions:\n  contents: read\n  pages: write\n  id-token: write\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: actions/setup-node@v4\n        with:\n          node-version: 24\n          cache: 'npm'\n      - run: npm ci --legacy-peer-deps\n      - run: npm run build\n      - name: Upload artifact\n        uses: actions/upload-pages-artifact@v3\n        with:\n          path: .output/public\n\n  deploy:\n    needs: build\n    runs-on: ubuntu-latest\n    environment:\n      name: github-pages\n    steps:\n      - uses: actions/deploy-pages@v4"
    },
    {
      "type": "paragraph",
      "html": "The workflow goes green. Deployment succeeds. You visit the site — <strong>404, page not found.</strong> Checking .output/public locally shows only assets/, favicon.ico, _headers, and robots.txt. No index.html.<!---->"
    },
    {
      "type": "paragraph",
      "html": "<strong>Why:</strong> TanStack Start is a full SSR framework. With Nitro's cloudflare-module preset (Lovable's default), the build output is a Worker bundle — .output/server/index.mjs — meant to render pages on demand, per request, inside Cloudflare's runtime. There's no static index.html because nothing is meant to be static; every route is rendered server-side at request time.<!---->"
    },
    {
      "type": "paragraph",
      "html": "GitHub Pages, on the other hand, is a dumb static file host. It has no runtime to execute a Worker. It just serves whatever's in .output/public — which, for an SSR build, is only the client-side JS/CSS assets, not page markup. Hence the 404.<!---->"
    },
    {
      "type": "paragraph",
      "html": "This is the core tension: <strong>static hosting and SSR hosting produce fundamentally different build outputs.</strong> You can't point a static host at an SSR build and expect it to work; you have to change what you're building.<!---->"
    },
    {
      "type": "heading",
      "level": 3,
      "text": "Failure #2: Enabling prerendering breaks the build"
    },
    {
      "type": "paragraph",
      "html": "The fix is to make TanStack Start actually prerender your routes into real HTML files. That's a built-in feature — prerender config under the tanstackStart plugin:<!---->"
    },
    {
      "type": "code",
      "lang": "nginx",
      "code": "export default defineConfig({\n  tanstackStart: {\n    server: { entry: \"server\" },\n    prerender: {\n      enabled: true,\n      autoSubfolderIndex: true,\n      autoStaticPathsDiscovery: true,\n      crawlLinks: true,\n    },\n  },\n});"
    },
    {
      "type": "paragraph",
      "html": "Rebuild, and it fails differently:<!---->"
    },
    {
      "type": "code",
      "lang": "text",
      "code": "[prerender] Crawling: /\nError [ERR_MODULE_NOT_FOUND]: Cannot find module '.../dist/server/server.js'\n...\nError: Failed to fetch /: Internal Server Error"
    },
    {
      "type": "paragraph",
      "html": "<strong>Why:</strong> The prerender step works by spinning up a local preview server and crawling your own routes to render them to disk. That local server needs to actually boot in plain Node. But Nitro was still building the cloudflare-module preset — a Worker-format bundle that Node can't execute directly, the way the prerender crawler expects. The crawler tries to hit /, the local server 500s immediately, and the whole prerender pass aborts, having rendered zero pages.<!---->"
    },
    {
      "type": "paragraph",
      "html": "This is a known friction point with TanStack Start + Nitro: <strong>the </strong>preset field isn't just an output-format toggle, it changes what runtime the build assumes it'll run in — and prerendering needs a Node-executable server to crawl against, regardless of what preset you ultimately want to ship.<!---->"
    },
    {
      "type": "heading",
      "level": 3,
      "text": "The actual fix: override the preset without duplicating the plugin"
    },
    {
      "type": "paragraph",
      "html": "Lovable's wrapper warns against adding a second nitro() plugin — but it turns out you can pass one through vite.plugins, and the wrapper is smart enough to treat it as an override rather than a duplicate:<!---->"
    },
    {
      "type": "code",
      "lang": "yaml",
      "code": "import { defineConfig } from \"@lovable.dev/vite-tanstack-config\";\nimport { nitro } from \"nitro/vite\";\n\nexport default defineConfig({\n  tanstackStart: {\n    server: { entry: \"server\" },\n    prerender: {\n      enabled: true,\n      autoSubfolderIndex: true,\n      autoStaticPathsDiscovery: true,\n      crawlLinks: true,\n    },\n  },\n  vite: {\n    plugins: [nitro({ preset: \"node-server\" })],\n  },\n});"
    },
    {
      "type": "paragraph",
      "html": "Rebuild, and this time the crawler successfully boots a local Node server, walks every route (including dynamic ones discovered via crawlLinks), and writes real HTML to .output/public:<!---->"
    },
    {
      "type": "code",
      "lang": "text",
      "code": "[prerender] Prerendering pages...\n[prerender] Crawling: /\n[prerender] Crawling: /blogs\n[prerender] Crawling: /blog/my-first-post\n...\n[prerender] Prerendered 283 pages"
    },
    {
      "type": "paragraph",
      "html": ".output/public/index.html exists. .output/public/blogs/index.html exists. Every blog post has its own folder with its own index.html. That's exactly the shape GitHub Pages (or any static host) needs.<!---->"
    },
    {
      "type": "heading",
      "level": 3,
      "text": "Wiring it into GitHub Actions"
    },
    {
      "type": "paragraph",
      "html": "The workflow itself doesn't need to change — it was already uploading .output/public as the Pages artifact. The bug was entirely in what was npm run build produced, not in the CI steps. Once the Vite config is fixed, the same workflow just works:<!---->"
    },
    {
      "type": "code",
      "lang": "yaml",
      "code": "- run: npm ci --legacy-peer-deps\n- run: npm run build # now produces real HTML in .output/public\n- uses: actions/upload-pages-artifact@v3\n  with:\n    path: .output/public\n- uses: actions/deploy-pages@v4"
    },
    {
      "type": "paragraph",
      "html": "Two things worth verifying before you trust it in CI:<!---->"
    },
    {
      "type": "list",
      "ordered": true,
      "items": [
        "<strong>Locally reproduce first.</strong> Run npm run build on your machine and check .output/public/index.html exists before pushing. It's much faster to iterate on Nitro preset issues locally than to wait on a CI round-trip each time.<!---->",
        "<strong>Watch for server-only routes.</strong> Prerendering runs your loaders at build time with no live request context. If any route calls a server function that depends on per-request data (auth, live database state, secrets), that route can't be meaningfully static — either exclude it via the filter option in the prerender config, or accept that it needs actual SSR hosting (Cloudflare, Vercel, Node) instead of a static host.<!---->"
      ]
    },
    {
      "type": "heading",
      "level": 3,
      "text": "The domain layer: DNS and HTTPS"
    },
    {
      "type": "paragraph",
      "html": "Getting the build right is necessary but not sufficient — a custom domain on GitHub Pages has its own two-stage gotcha.<!---->"
    },
    {
      "type": "paragraph",
      "html": "<strong>Stage one: DNS.</strong> For an apex domain like example.com (not www.example.com), You need four <strong>A records</strong> pointing at GitHub's Pages IPs — a CNAME doesn't work at the root per DNS spec:<!---->"
    },
    {
      "type": "code",
      "lang": "text",
      "code": "185.199.108.153\n185.199.109.153\n185.199.110.153\n185.199.111.153"
    },
    {
      "type": "paragraph",
      "html": "If you also want www to work, add a CNAME there, pointing at &lt;username&gt;.github.io.<!---->"
    },
    {
      "type": "paragraph",
      "html": "<strong>Stage two: HTTPS won't provision until GitHub can verify the DNS.</strong> Until it does, GitHub happily serves your site over plain HTTP and shows \"DNS Check in Progress\" / \"Enforce HTTPS — Unavailable\" in the Pages settings. This is expected, not broken — GitHub needs to independently confirm the domain before it'll request a Let's Encrypt certificate for it.<!---->"
    },
    {
      "type": "paragraph",
      "html": "If DNS looks completely correct (dig shows the right A records, the right NS delegation, no blocking CAA record, no stray DNSSEC keys) But GitHub still reports InvalidDNSError, that's almost always GitHub's own checker being stale rather than an actual misconfiguration — especially right after switching nameservers or adding a domain for the first time. The fix is mechanical: remove the custom domain in Pages settings, save, wait a minute, re-add it, save again. That forces a fresh check instead of reusing a cached failure.<!---->"
    },
    {
      "type": "heading",
      "level": 3,
      "text": "Summary"
    },
    {
      "type": "paragraph",
      "html": "The whole chain, in order:<!---->"
    },
    {
      "type": "list",
      "ordered": true,
      "items": [
        "<strong>Lovable's default Nitro preset targets Cloudflare Workers (SSR), not static hosting</strong> — this is invisible until you deploy somewhere that isn't Cloudflare.<!---->",
        "<strong>Static hosts need real HTML files.</strong> Enable TanStack Start's prerender option to generate them.<!---->",
        "<strong>Prerendering needs a Node-bootable server to crawl</strong>, so the Nitro preset has to be overridden  node-server for the build — even though you're ultimately shipping static files, not running that server anywhere.<!---->",
        "<strong>The GitHub Actions workflow itself was never the problem</strong> — it was faithfully uploading whatever it .output/public contained; the fix was entirely upstream, in what the build produced.<!---->",
        "<strong>Custom domains add a second, independent layer</strong>: correct DNS is necessary before GitHub will even attempt to issue HTTPS, and the platform's own DNS checker can lag behind reality.<!---->"
      ]
    },
    {
      "type": "paragraph",
      "html": "None of this is exotic — it's the standard SSR-vs-static tension every modern meta-framework has, just surfaced through Lovable's specific defaults. Once you see where the seam is (Nitro preset ≠ deployment target, they're two separate decisions), the fix is a few lines of config, not a framework fight.<!---->"
    },
    {
      "type": "paragraph",
      "html": "Tags: Go, TanStack Start, GitHub Actions, GitHub Pages, DevOps<!---->"
    }
  ]
}
