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(ormain)
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: [email protected] from lock file
npm error Missing: [email protected] 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 [email protected] 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 [email protected] 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 cilocally - 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.