◀ Back to blog
DevOps

Deploying Nuxt to Cloudflare Pages: pitfalls to avoid

Published on 26 Sep 2026· 5 min read
#Nuxt#Cloudflare#DevOps#CI/CD

A portfolio served from Cloudflare's network

This site is a Nuxt 3 application where every page is prerendered at build time: the home page, the resume pages and every blog article. Cloudflare Pages is an ideal host for this: HTML served from Cloudflare's global network, automatic HTTPS, a deployment on every push and a preview for every branch. Here is the setup, and above all the three pitfalls I ran into during the latest redesign.

Build settings

In the dashboard (Workers & Pages → your project → Settings → Build):

  • Build command: npm run build
  • Output directory: dist
  • Production branch: master (or main)

Nuxt automatically detects the Cloudflare Pages environment and uses the cloudflare-pages Nitro preset: prerendered routes become static HTML files in dist/, and everything else is served by a Worker. For a 100% static site, declare the routes to prerender in nuxt.config.ts. Generating them from your data means you never forget an article:

import { blogArticles } from './data/blog'

const staticPages = ['/', '/experience', '/skills', '/projects', '/education', '/blog']
const blogPages = blogArticles.map(article => `/blog/${article.slug}`)

export default defineNuxtConfig({
  nitro: {
    prerender: {
      routes: [...staticPages, ...blogPages],
      crawlLinks: true,
    },
  },
})

The same array feeds the sitemap: a new article is prerendered and listed automatically, without touching anything else.

Pitfall #1: the production branch

After the redesign was pushed to master, the live site had not changed, even though the build succeeded. The project's production branch was still set to an old work branch, so every push to master only produced a preview deployment on a *.pages.dev URL, without touching the main domain.

Check it under Settings → Build → Branch control. And changing the production branch does not redeploy anything: you need a new commit on that branch (or to retry a deployment) for production to be updated.

Pitfall #2: the Node version

Nuxt 3.21, Vite 7 and Nitro require Node ^20.19 or >=22.12. Cloudflare's build system reads the version from a .node-version or .nvmrc file at the root of the repository (or from the NODE_VERSION environment variable). A bare 20 is ambiguous: pin a recent major version that is clearly compatible.

echo 22 > .node-version
echo 22 > .nvmrc

Pitfall #3: the lockfile and npm ci

The build failed within seconds with this message:

npm error `npm ci` can only install packages when your package.json and
package-lock.json or npm-shrinkwrap.json are in sync.
npm error Missing: oxc-parser@0.151.0 from lock file
npm error Missing: esbuild@0.28.2 from lock file
...

Yet npm install and the build worked perfectly locally. The cause: the package-lock.json had been generated with npm 11 (shipped with Node 24), while Cloudflare installs dependencies with npm ci on npm 10. The two versions do not resolve optional dependencies the same way, and npm 10 considered the lockfile out of sync.

The fix: regenerate the lockfile with the same npm version as the CI, declared in the packageManager field of package.json:

npx npm@10.9.4 install --package-lock-only

And above all, reproduce the CI locally before pushing, on a clean copy of the repository:

git clone . /tmp/ci-check && cd /tmp/ci-check
npx npm@10.9.4 ci
npm run build

If these two commands succeed, the Cloudflare build will too.

Trailing slash redirects

Once live, curl -I https://benmacha.tn/experience returns a 308 to /experience/. This is not a bug: each prerendered page is an experience/index.html file, and Cloudflare Pages redirects to the directory URL. For SEO, keep internal links and canonical URLs consistent with this behavior to avoid a redirect on every click.

Following a deployment without opening the dashboard

Cloudflare reports the status of each build to GitHub as a check run attached to the commit. With the GitHub CLI, you can follow the deployment from the terminal:

gh api repos/MOI/MON-REPO/commits/$(git rev-parse HEAD)/check-runs \
  --jq '.check_runs[] | select(.name=="Cloudflare Pages") | "\(.status) \(.conclusion)"'

The result goes from in_progress to completed success, or completed failure. In the latter case, the check's details_url link leads straight to the build logs.

In short

  • Make sure the production branch is the one you push to
  • Pin the Node version in .node-version, compatible with your dependencies
  • Generate the lockfile with the same npm version as the CI, and test npm ci locally
  • Generate the prerendered routes and the sitemap from your data

Once these points are sorted, the workflow is ideal: one git push, a minute of build, and the site is up to date all over the world.