Static Export on Next.js, S3, and CloudFront: What $0.62/month Bought and What It Cost
This blog has no server, and that one decision sets its cost, its content pipeline, and its two workarounds: clean URLs at the edge and analytics fetched at build time. The tradeoffs, as actually paid.
This site costs about $0.62 a month to run, and that number is the consequence of one decision: there is no server. Next.js builds to a folder of HTML, S3 stores the folder, CloudFront serves it. Everything else in the architecture is either a consequence of that decision or a workaround for it, and the workarounds are where the engineering actually lives.
(Updated September 2026 — rewritten from a stack tour into the decision and its consequences. The architecture is unchanged.)
The decision, and where it's enforced
Static export means Next.js emits plain files at build time and nothing runs in production. The switch is in the config, and it's deliberately narrow:
const isStaticExport =
process.env.NODE_ENV === "production" && process.env.GITHUB_ACTIONS === "true";
// ...
...(isStaticExport && { output: "export", trailingSlash: true }),
Only the CI build exports. Local development runs the full Next runtime with fast refresh, and producing the exported folder locally means setting that variable by hand. The split was intentional: development gets the ergonomics, production gets nothing to operate.
The cost of the split is that the two environments differ, so one class of bug only appears under export. The example from this site: useSearchParams needs a Suspense boundary under static export and works without one in dev, so the sandbox pages each wrap their tool in one. The URL-state post covers it. If you gate export on an environment variable, budget for finding that class of bug in CI rather than at your desk.
The stack
| Layer | Technology | Purpose | |-------|------------|---------| | Framework | Next.js 16 (App Router) | React-based static site generation | | Content | MDX | Markdown with embedded React components | | Styling | Tailwind CSS + Typography plugin | Utility-first CSS; theme-aware prose | | Hosting | AWS S3 + CloudFront | Static file storage and CDN | | CI/CD | GitHub Actions | Automated builds and deployments | | Analytics | Google Analytics 4 | Traffic data; view counts collected at build |
Posts are MDX files in src/content/ with YAML frontmatter. The frontmatter is the metadata: date sorts the feed (the filename does not), tags and topics feed the knowledge graph, prerequisites and related draw its edges. A build script reads every file and writes a metadata index the listing pages consume. The index is generated output and never hand-edited, a rule I keep in the repo's guidelines because a hand edit there disappears on the next build.
src/content/*.mdx
↓
Build Script
↓
posts-metadata.json ← GA4 View Counts
↓
Next.js Build
↓
Static HTML
↓
S3 Bucket
↓
CloudFront CDN
↓
Users
Workaround 1: clean URLs need code at the edge
Export writes /about/index.html. Readers type /about. S3 as a REST origin does not bridge that gap on its own, so a CloudFront Function does it on every request:
Request: /about
↓
CloudFront Function
↓
Rewrite: /about/index.html
↓
S3 Response
The same function redirects www to the apex domain and skips the rewrite for framework assets, the feed, and the icon paths. It runs at the edge with no cold start, which matters because it sits in front of every request the site serves. trailingSlash: true in the Next config keeps the links the build emits consistent with what the rewrite expects, so a link that works in dev works on S3.
The function's code lives in the CloudFormation template, which puts it under the same drift check as the rest of the infrastructure. A change to URL handling is an infrastructure change, and it deploys like one.
Workaround 2: anything dynamic is fetched at build time
There is no runtime to call an API, so any data on a page has to be known when the page is built. The one piece of dynamic data this site wanted was per-post view counts from GA4. A script queries the GA4 Data API before each build and writes the results to a JSON file the build can read; no credentials ever reach the browser, and no visitor triggers an API call.
The honest status: the homepage no longer renders those counts. The widget came off; the pipeline stayed, still running on every deploy. That outcome, a working pipeline with no consumer, is its own lesson, and the GA4 post tells it, including the mock-data trap that pipeline grew.
Deployment
Every push to main runs the GitHub Actions workflow: a drift check that compares the CloudFormation template to the deployed stack, typecheck and lint, the GA4 fetch, the build, an S3 sync with two cache policies, a blocking CloudFront invalidation, and Playwright smoke tests against the live site. Push to live takes about 4–5 minutes. The pipeline post reads that workflow gate by gate, including the drift check, which has stopped deploys until the template was re-synced with the stack.
Cost
| Service | Monthly Cost | |---------|--------------| | S3 Storage | ~$0.02 | | CloudFront | ~$0.10 | | Route 53 | $0.50 | | Total | ~$0.62/month |
Most of that is the hosted zone. The storage and CDN lines are what a static folder costs to serve at this site's traffic, and they would stay small at a multiple of it. Not in the table: the domain registration, GitHub Actions minutes (free tier for a public repo), and the view-count Lambda, which is priced in the GA4 post.
What I gave up
Traded, not hypothetically but as paid:
- Freshness. Anything dynamic is as fresh as the last build or the scheduled Lambda. The view counts were the only case, and the site stopped displaying them.
- Comments and any reader interaction. There is no backend to receive them. I have no plan to add one.
- Search. Client-side only. The catalog is small enough that this has not mattered.
- One build path. Export is gated on CI, so the export-only bugs show up in CI. Cheaper than running export locally every time; costlier the day it bites.
- Infrastructure agility. The drift check that protects the edge config also blocks content deploys when the template and stack disagree. That's a gate I chose, and it has needed a re-sync commit to clear.
A quiet surface
The site chrome is restrained on purpose: Inter, one navy accent, whitespace. The playful engineering lives one keystroke away. Press the backtick key (`) on any page and an interactive terminal slides up. It used to have a button in the header. Why it lost the button is the next post, and it's the same adoption question I ask of any capability in a professional stack.
The tradeoff discipline on this page is the hobby-scale version of the one I run on revenue-technology stacks. Buy vs. Build for the GTM Stack is the grown-up version of these decisions: buy the commodity (S3, CloudFront, GA4), build the thin layer that's yours (the edge function, the content pipeline), and know which capabilities you've stopped operating.
Next up: Go-Live Is Not Adoption - why the terminal shell became an easter egg, and what that demotion has to do with enterprise shelfware.