Building a Blog Without a Server
A constraint-driven blog architecture for a static SvelteKit site on S3 + CloudFront, with independent content publishing and no request-time compute.
I’m a backend engineer, which means my reflex for “dynamic” is to add compute and permissions until the problem stops moving. This time I wanted the opposite: a blog grafted onto a static site, albeit one where publishing a post wouldn’t force a rebuild, and where the runtime footprint stayed boring enough to forget.
That set a few constraints that ended up doing most of the design work. Publishing needed to be decoupled from the app build, routing needed to stay content-agnostic, and the deployed system needed to avoid request-time compute and runtime credentials. I was willing to accept a small client-side loading state in exchange for something that didn’t grow an on-call surface area.
The key shift was treating a blog URL as a request for a shell, not for content. In the usual backend flow, the system routes, checks whether content exists, fetches it, renders, and returns HTML; each step assumes there’s a server that knows what posts are available. Here the edge routes blindly, the browser fetches the content directly as a file, and rendering happens client-side. CloudFront doesn’t need an index of posts to serve /blog/hello-world; it just needs to recognize “this looks like a blog slug” and rewrite it to a single page.
That page is _post.html. When you visit /blog/hello-world, a CloudFront Function rewrites the request to /blog/_post.html. The page reads the slug from the URL, fetches /blog/posts/hello-world.md, and renders it. If the object doesn’t exist, S3 returns a 404 and the client shows a not-found state. It’s a deliberately file-shaped system: the existence check is an HTTP fetch, and the content API is the object store.
The routing layer really is tiny:
var blogPostMatch = uri.match(/^\/blog\/([^\/]+)$/);
if (blogPostMatch && !uri.startsWith('/blog/posts/') && !blogPostMatch[1].includes('.')) {
uri = '/blog/_post.html';
}Once that’s in place, the more interesting part is deployment hygiene. I split “site deploy” from “blog publish” so each pipeline owns a narrow surface area. The site workflow builds SvelteKit output and syncs it to S3 while excluding the posts directory, which keeps ordinary frontend changes from touching content. The blog workflow triggers only when blog/posts/ changes; it generates an index.json and also prerenders each post to standalone HTML for crawlers and social preview bots, then syncs only the posts and related metadata.
Both workflows assume an AWS role via GitHub Actions OIDC, so deploy credentials are short-lived and scoped to the repository and branch that’s allowed to publish. That keeps secrets out of the repo settings and reduces the blast radius of accidental leakage, while still being simple enough to forget about day to day.
SEO is the one place where this architecture asks you to make a pragmatic decision. Client-side rendering is fine for modern search engines, but plenty of social preview bots don’t execute JavaScript, so the publish step emits HTML versions of each post. At the edge I route those bots to the prerendered file based on a user-agent regex, while humans get the shell, which keeps previews working without requiring the whole site to become fully statically generated. It’s a little brittle, and it’s also easy to delete later if I decide the simplest future is serving prerendered HTML to everyone.
Caching is where the system becomes predictable. Hashed SvelteKit assets can sit at a year-long TTL, while the HTML shell and prerendered pages are set to revalidate so template changes propagate quickly. Markdown and JSON can use a modest TTL, with invalidations as an accelerator rather than a requirement, which means failures tend to degrade into staleness rather than outages.
The tradeoffs are real, but they’re the kind that feel honest for a personal site. The first paint shows a loading state while the markdown fetch completes, the publish path runs through CI, and anything interactive (comments, auth, personalization) pushes you toward third-party services or back toward compute. In return, the runtime shape stays almost embarrassingly small, failures tend to be debuggable with a browser and CloudFront logs, and the monthly cost usually ends up being dominated by the hosted zone rather than by invocations.
If I ever outgrow this, the migration path is clean: prerender everything, serve static HTML universally, and keep the same storage and CDN story. Until then, a few hundred lines of Svelte, a small CloudFront Function, and two workflows get me a blog that behaves like a set of files, publishes quickly, and doesn’t recruit any new services into my life.