How We Built a Full Marketplace With Python, SQLite, and Zero JS Frameworks
No React. No Vue. No build step. Just Python f-strings, SQLite with WAL mode, and a single Fly.io machine serving 100+ tools.
The Contrarian Choice
Everyone told us we needed React. Or at the very least Vue, Svelte, something with a virtual DOM and a build pipeline. We were building IndieStack — a full marketplace with authentication, Stripe Connect payments, full-text search, an admin dashboard, maker profiles, wishlists, reviews, and an MCP server that plugs into AI coding assistants. Not a todo app. A real product with real money flowing through it.
We built the entire thing with Python f-strings, SQLite, and zero JavaScript
frameworks. No React. No Vue. No Next.js. No build step. No node_modules.
And after six rounds of development, we are more convinced than ever that this
was the right call.
This is not a contrarian take for the sake of being contrarian. It is a practical report from two university students who needed to ship fast, iterate faster, and not lose entire weekends debugging webpack configurations.
Why Python String Templates
Every route in IndieStack is a Python function that returns an HTML string. No
Jinja2. No template engine. No special syntax to learn. You import
page_shell() from a shared components module, compose your HTML
inline using f-strings, and return it. That is the entire rendering model.
Here is a simplified version of what a route looks like:
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from indiestack.routes.components import page_shell
router = APIRouter()
@router.get("/about", response_class=HTMLResponse)
async def about_page(request: Request):
body = f"""
<div style="max-width:800px; margin:40px auto;">
<h1>About IndieStack</h1>
<p>A curated marketplace for indie SaaS tools.</p>
</div>
"""
return HTMLResponse(page_shell("About", body,
user=request.state.user))
The benefits are surprisingly significant. There is zero build step — you edit a Python file, save it, and the change is live on the next request. The template is the code, so there is no context-switching between a templating language and your application logic. There is no template inheritance to debug, no partial rendering quirks, no cache invalidation headaches. If you can read Python, you can read every template in the application.
The page_shell() function handles the HTML boilerplate: the
<head> tag with fonts and CSS custom properties, the navigation
bar, the footer, dark mode support, and meta tags. Every route just provides a
title and a body string. Shared components like tool_card() are
regular Python functions that return HTML strings. Composition is just string
concatenation.
The SQLite Bet
IndieStack runs on a single SQLite database file with WAL mode enabled. Twenty-nine tables. FTS5 for full-text search across tool names, descriptions, and tags. The entire database sits on the same Fly.io machine that serves the application.
This gives us properties that are genuinely difficult to replicate with Postgres
or MySQL in a typical deployment. There is no connection pooling to configure. No
separate database server to provision, monitor, or pay for. Backup is literally
cp database.db database.backup.db. Reads are fast because there is
zero network latency — the data is on the same disk as the application.
WAL mode means readers never block writers and writers never block readers, which is the main concurrency bottleneck people worry about with SQLite in web applications. For our workload — a marketplace with modest write volume and heavier read traffic — it is more than sufficient.
We are honest about the limits. SQLite will not scale to millions of concurrent writes. If we ever need to shard data across regions or handle thousands of simultaneous write transactions, we will need to migrate to Postgres. But that is not the problem we have today. The problem we had today was shipping a working marketplace, and SQLite let us do that in a weekend instead of a week.
What We Actually Use JavaScript For
We are not JavaScript puritans. We use inline vanilla JS for exactly four things:
the dark mode toggle (reads a localStorage key, toggles a class on
<html>), the mobile navigation hamburger menu (toggles visibility
on a div), hover effects on interactive cards (inline onmouseover /
onmouseout handlers), and auto-refresh on the /live page
(a single <meta http-equiv="refresh"> tag).
Every piece of JavaScript in the application is under twenty lines. There is no
npm. No node_modules. No webpack, Vite, esbuild, or Turbopack. No
package.json. The entire client-side behaviour is vanilla JS inlined
in the HTML, and it works in every browser without transpilation.
The DX Wins
Deploying IndieStack is one command: flyctl deploy. No CI/CD pipeline
to configure. No Docker Compose orchestration. No 47 microservices to health-check.
The Dockerfile installs Python dependencies, copies the source tree, and runs
uvicorn. That is the entire infrastructure.
The entire application is roughly 6,000 lines of Python across 21 route files plus a handful of supporting modules for auth, email, payments, and the database layer. A new developer can read every line of code in an afternoon. Not skim it — actually read it, understand the data model, trace a request from URL to HTML response, and start making changes.
When something breaks, the stack trace points to a Python function that returns an HTML string. There is no hydration mismatch to debug. No stale client-side state. No race condition between server-rendered markup and client-side JavaScript that tries to take over the DOM. The server renders HTML. The browser displays it. That is the entire mental model.
When This Breaks
We would be dishonest if we did not talk about the failure modes. If IndieStack hits 10,000 concurrent users, SQLite writes will bottleneck. The single-writer lock in WAL mode means write transactions queue up, and at high enough volume that queue becomes the limiting factor. We would need to migrate to Postgres or Turso at that point.
If we need real-time collaborative features — live comments, presence
indicators, collaborative editing — we will need WebSockets. Our current
architecture is pure request-response. The /live page fakes
real-time by refreshing every 15 seconds with a meta tag. That is not going to
cut it for genuine real-time experiences.
If we want client-side navigation (instant page transitions without full reloads), we would need to either adopt HTMX or build a proper SPA. Our current approach means every navigation is a full page load. It is fast because the pages are small, but it is not as slick as a well-built React app with optimistic updates and prefetching.
But here is the thing: we do not have these problems. We have zero users. The marketplace is live, the tools are listed, and the infrastructure can handle orders of magnitude more traffic than we currently receive. The framework that ships is better than the framework that is perfect.
Premature optimisation is the root of all evil. Premature architecture is its quieter, more expensive cousin.
The Takeaway
The best stack is the one that lets you ship. Ours happens to be Python f-strings, SQLite, and a single Fly.io machine. No framework orthodoxy. No build toolchain theology. Just the simplest thing that works for the problem in front of us.
If you are a solo developer or a small team and you are spending more time configuring your toolchain than building your product, consider whether you actually need all that machinery. Maybe you do. But maybe — like us — you do not.
Browse IndieStack to see the result. Or check out who we are — two uni students in Cardiff who refuse to overcomplicate things.