BitFictionBitFiction
← Journal
devopstanstackawslovable

From prototype to production: how bitfiction.com is built and hosted

June 15, 2026 · 6 min read

This site is the first thing we shipped under the new BitFiction banner, so it felt right to make the first journal entry the site itself: how it gets from an idea typed into a chat box to bytes served from a CloudFront edge near you.

The short version: prototype in [Lovable](https://lovable.dev), productionise on our own AWS, automate everything in between with [Staticbot](https://staticbot.dev) and [GitHub](https://github.com/bitfiction). No servers to babysit, no platform lock-in, and a path off the prototyping tool the moment we want one.

Stage 1 — Prototype in Lovable

Lovable is where the site is actually built. We describe what we want in plain English, it edits the codebase, and a live preview updates in seconds. For a marketing site with a journal, that loop is hard to beat — copy changes, layout tweaks, new routes, all in the time it takes to write a Slack message.

Two things matter for the rest of this story:

  • The project lives in a real Git repo. Lovable pushes every change to GitHub on our behalf — there is no proprietary export step.
  • The stack underneath is plain TanStack Start on Vite. No magic runtime, no hidden services. If Lovable disappeared tomorrow, the repo still builds with npm install && npm run build.

That's the whole point of "prototype, don't lock in": the velocity is Lovable's, but the artefact is ours.

Stage 2 — TanStack Start, used as a static SPA

TanStack Start is a full-stack React framework — it can do SSR, server functions, the lot. For this site we deliberately use only the client half: file-based routing, typed links, head metadata, prerendered HTML. No server runtime in production.

That decision is one config block:

// vite.config.ts
import { defineConfig } from "@lovable.dev/vite-tanstack-config";

export default defineConfig({
  tanstackStart: {
    // SPA mode: no SSR. Prerender static routes to HTML for SEO/CloudFront.
    spa: { enabled: true },
    prerender: {
      enabled: true,
      crawlLinks: true,
      // Skip dynamic routes — /blog/$slug is handled client-side at runtime.
      filter: ({ path }) => !path.startsWith("/blog/"),
    },
  },
  nitro: false,
});

At build time the prerenderer crawls every Link from / and emits a real index.html for each route — /, /about, /services, /products, /contact, /blog. Each page ships with its own title, meta description, and Open Graph tags, so crawlers and link unfurlers see the right thing without ever executing JavaScript.

Dynamic routes like /blog/$slug are different. There is no list of slugs to bake at build time, so we skip them in the prerender filter. The SPA shell loads, TanStack Router matches the URL on the client, and the route's loader returns the post:

// src/routes/blog.$slug.tsx
export const Route = createFileRoute("/blog/$slug")({
  loader: ({ params }) => {
    const post = getPost(params.slug);
    if (!post) throw notFound();
    return { post };
  },
  head: ({ loaderData, params }) => ({
    meta: [
      { title: loaderData?.post?.title + " — BitFiction" },
      { name: "description", content: loaderData?.post?.excerpt ?? "" },
      { property: "og:url", content: "/blog/" + params.slug },
    ],
  }),
  component: PostPage,
});

The post bodies are just typed objects in src/content/posts.ts — no CMS, no database call, no API. The "blog" you're reading right now is literally a TypeScript array compiled into the bundle. That keeps the static-hosting story honest: there is nothing to query.

After Vite finishes, a small post-build script flattens the output into something S3 can serve directly:

// scripts/postbuild-static.mjs (abridged)
// TanStack emits dist/client/* and dist/server/*. For static hosting
// we want dist/* with index.html at the root and no SSR bundle.
if (!existsSync(rootIndex)) await cp(shell, rootIndex);
if (existsSync(serverDir)) await rm(serverDir, { recursive: true });
for (const entry of await readdir(clientDir, { withFileTypes: true })) {
  await cp(join(clientDir, entry.name), join(distDir, entry.name), { recursive: true });
}

The result is a dist/ folder full of HTML, JS, CSS, and assets — nothing else.

Stage 3 — [Staticbot](https://staticbot.dev) wires up S3 + CloudFront

This is where Staticbot earns its keep. The deal is simple: point it at our GitHub repo and our AWS account, and it stands up the production stack for a static site — and then keeps deploying to it on every push.

Concretely, for bitfiction.com it provisions:

  • An S3 bucket that holds the contents of dist/.
  • A CloudFront distribution in front of the bucket, so the site is served from edge locations globally instead of a single region.
  • An ACM certificate for bitfiction.com and www.bitfiction.com, with SSL renewals handled automatically.
  • The Route 53 DNS records that point the apex and www at CloudFront.
  • The CloudFront behaviours that make an SPA actually work on object storage — most importantly, mapping not-found object requests back to /index.html so client-side routes like /blog/some-post resolve correctly on a hard refresh.

Two details we deliberately leaned into:

  • The infrastructure is ours. Staticbot deploys into our AWS account, and the Terraform that defines the stack is written to our S3 bucket. If we ever want to move on, we run terraform apply ourselves and nothing changes for visitors.
  • No third-party hosting markup. We pay AWS directly for the few cents a month an S3 + CloudFront site actually costs at our traffic. There is no "per-seat" or "per-bandwidth" middleman.

The mental model is: [Staticbot](https://staticbot.dev) is a build pipeline and a Terraform operator, not a host.

Stage 4 — GitHub is the deploy button

Tying it all together: GitHub is the single source of truth and the only thing that triggers a deploy.

The flow looks like this:

  • Edit copy or code in Lovable (or locally — both work, the repo is normal).
  • Lovable commits and pushes to the main branch on GitHub.
  • Staticbot sees the push, runs npm ci && npm run build, and syncs the resulting dist/ to the S3 bucket.
  • A CloudFront invalidation goes out so the edge caches pick up the new HTML and assets.

There is no "deploy" button to forget to press, no environment that drifts from the repo, and no manual step where a human has to remember to upload something. A merged commit is a deployed site, usually within a minute or two.

Prototype where you love. Ship where you own. Let the robots move the bytes.

Why this shape

We picked this stack because it answers three questions we actually care about, in this order:

  • How fast can we change the site? Lovable + TanStack file routes means a new page or a copy edit is seconds of work.
  • What happens if a vendor disappears? Lovable writes plain TypeScript into our GitHub repo. Staticbot writes plain Terraform into our S3 bucket. AWS is AWS. Every layer is independently replaceable.
  • What does it cost to run? A prerendered SPA on S3 + CloudFront is effectively free at marketing-site traffic, and scales to a Hacker News front page without anyone waking up.

That's the whole stack. The next posts in this journal will get further into the weeds — agentic DevOps loops, long-running workflows, and the human escape hatches that keep them safe — but it felt right to start with the floor we're standing on.