Back to the blog

Shipping Static Sites with Nuxt

How I build and deploy a Nuxt site to Cloudflare Pages with predictable routes and content.


Shipping Static Sites with Nuxt

Nuxt fits portfolio and blog sites well: fast prerendered pages, edge hosting, and API routes when you need them. Here is the workflow I use for this site on Cloudflare Pages: content in git, builds that match production, and routes that exist before deploy day.

Content as files

Markdown in content/blog/ keeps posts reviewable in git. A small script turns those files into JSON the pages can fetch at build and runtime. The pipeline is:

  1. Write or update a markdown post
  2. Run the blog JSON updater
  3. Build for Cloudflare Pages (nuxt build with the cloudflare_pages preset)
  4. Deploy the dist output

Keeping content and the generated index in sync is more reliable than relying on runtime markdown parsing alone. Generated artifacts can live in public/ so the static host serves them as plain files. Pages fetch /blog-posts.json or per-slug JSON without needing a content database.

Why this shape works for me:

  • Pull requests show real post diffs, not CMS admin screenshots
  • Local preview uses the same files CI will build
  • Prerender can enumerate every post path from the index before the crawl finishes guessing

When I add a post, I treat “forgot to run the updater” as a first-class failure mode. Document the command next to “how to run locally.” If the updater is cheap, wire it into a prebuild script so npm run build cannot ship stale JSON by accident:

{
  "scripts": {
    "update-blog": "node scripts/update-blog.mjs",
    "prebuild": "npm run update-blog",
    "build": "nuxt build"
  }
}

Content files should stay boring: YAML frontmatter for title, description, date, tags; markdown body for the article. Clever runtime transforms belong in the generator script, not in one-off page logic that only you remember.

Build, don’t guess

Treat npm run build as the source of truth for what will go live. Local builds should match Cloudflare Pages closely: same Node version, same env vars, same content files.

For this project, blog routes are derived from a JSON index so prerender knows every post path ahead of time. That avoids “works in dev, 404 in production” surprises. Dev servers are forgiving; static hosts are not. If a route is not generated, it does not exist.

Practical habits:

  • Pin or document the Node major version Pages uses
  • Run a production build before merging large routing changes
  • Fail the build when required content or env is missing instead of shipping empty shells
  • Keep nuxt.config prerender/crawl settings intentional; do not rely on accidental link discovery alone for important pages

Example sketch for feeding routes from an index:

// nuxt.config.ts (illustrative)
import { readFileSync } from 'node:fs'

const posts = JSON.parse(readFileSync('./public/blog-posts.json', 'utf8'))
const postRoutes = posts.map((p) => `/blog/${p.slug}`)

export default defineNuxtConfig({
  nitro: {
    prerender: {
      routes: ['/', '/blog', ...postRoutes]
    }
  }
})

Your exact config may differ; the principle does not: enumerate what must exist, then verify the build output contains those paths.

I also keep deploy previews. Opening the Pages preview URL for a PR catches asset path issues and missing routes before main moves. Screenshots help, but clicking the new post link is the real test.

Trailing slashes and base paths

Cloudflare Pages (and most static hosts) are picky about URLs. Decide early:

  • Trailing slash policy (/blog/ vs /blog)
  • Whether the site lives at the domain root or a project subpath

Then configure Nuxt once and stick to it. Mixed link styles are a common source of broken navigation after deploy: internal links that work in the Vite mid-dev server can 404 on a host that normalizes paths differently.

Pick one canonical form and generate sitemaps, <NuxtLink> targets, and canonical meta tags to match. If you change policy later, plan redirects instead of hoping old links forgive you.

For project sites under a subpath, set app.baseURL (or the equivalent) and test with that base locally. Relative asset URLs that look fine on localhost/ break on https://example.com/repo-name/ when the base is wrong.

A short verification pass after deploy:

  1. Home loads
  2. Blog index lists posts
  3. One old post and one new post open
  4. Direct refresh on a deep link still works (no client-only router fantasy)
  5. sitemap.xml URLs match the live trailing-slash policy

That list has caught more regressions for me than reading config diffs alone.

Keep deploys boring

Boring deploys are a feature. Prefer push-to-main with a known build command over manual wrangler rituals you only remember twice a year. Store secrets in the host dashboard, not in chat logs. When something fails, read the build log first; most “Nuxt is broken” moments are missing env, stale lockfiles, or a content step skipped in CI.

If you need server routes (contact forms, lightly protected APIs), keep them small and explicit under Nitro. The static pages remain the product; the functions are escape hatches. Growing a full app server on top of a portfolio host is possible, but then reassess whether “static site” is still the right framing.

Wrap-up

Nuxt on Cloudflare Pages ships best when builds are boring and predictable: content in git, routes enumerated, build identical in CI and locally. The less special-casing you need at deploy time, the fewer late-night 404s you get, and the more evenings you spend writing posts instead of debugging paths.