Building wilianrevejes.dev: a static Next.js site on Cloudflare Workers

July 5, 2026

First post, so a quick tour of how this site is put together.

The stack

  • Next.js (App Router) with output: "export" — the whole site compiles to static HTML at build time. No server, no cold starts, nothing to patch at runtime.
  • Based on Dillion Verma's Magic UI portfolio template (MIT) — all personal content lives in a single resume.tsx config file, which keeps content changes trivial.
  • Tailwind CSS v4 + shadcn/ui + Magic UI for styling and the small animations.
  • content-collections for the blog — posts are .mdx files in a content/ directory, with frontmatter validated by Zod at build time. The schema is strict on purpose: publishedAt is checked as an ISO date, so a typo in the field name or the value fails the build instead of silently rendering "Invalid Date".

Deploys

The repo is connected to Workers Builds: every push to main runs next build and publishes the out/ directory as Workers static assets on this domain. Rollback is redeploying a previous build.

Push-to-deploy cuts both ways, though — with no staging step, a half-written post would go live the moment it's committed. So the content pipeline has one gate: posts marked draft: true are filtered out of the routes, the blog index, and the sitemap at build time. A draft can sit on main indefinitely without ever being published.

Security headers ship from a _headers file next to the static assets — CSP, HSTS, nosniff, X-Frame-Options: DENY — and the zone redirects all HTTP to HTTPS at the edge, so none of it depends on application code.

The gotcha that almost blocked this post

output: "export" has one sharp edge: a dynamic route like blog/[slug] fails the entire build if its generateStaticParams() returns an empty array. With zero posts, the blog index page built fine — but the post route itself couldn't.

The workaround while the blog was empty: name the route folder _slug instead of [slug]. The underscore prefix marks it as a private folder, excluding it from routing entirely, so the export never tries to build it. The rename back to [slug] shipped in the same commit as this first post — the commit that published the page you're reading.