Zero to Production: My Nuxt + Vercel Deployment Pipeline
The actual configuration, gotchas, and decisions behind deploying a Nuxt 4 app with SSR, prerendering, and a content module to Vercel — including the sqlite issue that cost me an afternoon.

James Ross Jr.
Full-Stack Developer & Systems Architect
The Setup
This post documents the production deployment configuration for a Nuxt 4 app on Vercel — specifically this portfolio. The setup involves:
- Nuxt 4 with
ssr: trueandpreset: 'vercel' @nuxt/contentv3 using SQLite for content indexing- Selective prerendering — some routes prerendered at build time, others SSR at request time
- GitHub → Vercel for automatic deploys on push to
main
I'll cover what works, what doesn't, and the non-obvious configurations that took me time to figure out.
The vercel.json Configuration
Start here. Vercel auto-detects Nuxt, but you need explicit configuration for:
- The build command (because of the native module issue — more on this below)
- Security headers
- Permanent redirects
{
"framework": "nuxtjs",
"installCommand": "pnpm install",
"buildCommand": "pnpm rebuild better-sqlite3 && pnpm run build",
"outputDirectory": ".output/public",
"redirects": [
{
"source": "/(.*)",
"has": [{ "type": "host", "value": "jamesrossjr.com" }],
"destination": "https://www.jamesrossjr.com/$1",
"permanent": true
}
],
"headers": [
{
"source": "/(.*)",
"headers": [
{ "key": "X-Frame-Options", "value": "DENY" },
{ "key": "X-Content-Type-Options", "value": "nosniff" },
{ "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" }
]
},
{
"source": "/_nuxt/(.*)",
"headers": [
{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }
]
}
]
}
The outputDirectory matters — Nuxt 4 with the Vercel preset outputs to .output/public, not dist or .nuxt/dist/client.
The better-sqlite3 Problem
@nuxt/content v3 uses SQLite to index your content at build time. The SQLite driver is better-sqlite3, a native Node.js module that compiles against the platform's architecture.
On Vercel, the build runs on Linux x64. If you're developing on Apple Silicon (M1/M2/M3), your local node_modules/better-sqlite3 was compiled for ARM64. When Vercel pulls your installed dependencies, the binary is wrong for their infrastructure.
The fix is simple: rebuild the native module during the Vercel build step.
"buildCommand": "pnpm rebuild better-sqlite3 && pnpm run build"
Without this, you get a cryptic error during deployment that mentions failed to load native addon or similar. This burned me for a few hours before I understood what was happening.
If you're using npm instead of pnpm:
"buildCommand": "npm rebuild better-sqlite3 && npm run build"
Nuxt Config: SSR + Selective Prerender
The nuxt.config.ts nitro configuration controls what gets prerendered vs SSR'd:
nitro: {
preset: 'vercel',
prerender: {
crawlLinks: true,
routes: [
'/',
'/blog',
'/services',
'/services/architecture-consulting',
'/services/digital-transformation',
'/services/web-development',
'/services/software-development',
'/portfolio',
'/portfolio/bastionglass',
'/portfolio/myautoglassrehab',
...blogSlugs.map(slug => `/blog/${slug}`)
]
}
}
crawlLinks: true tells Nuxt to follow links found in prerendered pages and prerender those too. This catches any routes you forgot to explicitly list.
Why prerender at all? For a portfolio/blog, content changes rarely. Prerendered pages load from Vercel's CDN with zero server compute. For blog posts specifically, prerendering means crawlers get fully-rendered HTML — which is the whole point of fixing the SSR issue.
The blog slug array is dynamically built at config time:
import { readdirSync } from 'fs'
import { join, basename } from 'path'
const blogSlugs = readdirSync(join(__dirname, 'content/blog'))
.filter(f => f.endsWith('.md'))
.map(f => basename(f, '.md'))
This means every .md file in content/blog/ is automatically added to the prerender list. No manual maintenance.
Content Module Configuration
@nuxt/content v3 requires a content.config.ts at the project root:
import { defineCollection, defineContentConfig } from '@nuxt/content'
export default defineContentConfig({
collections: {
blog: defineCollection({
type: 'page',
source: 'blog/*.md'
})
}
})
Without this file, the module doesn't know your content structure and queryCollection() returns nothing. This is the most common gotcha — the documentation mentions it, but not prominently enough.
In the page component:
const { data: article } = await useAsyncData(`blog-${slug}`, () =>
queryCollection('blog').path(`/blog/${slug}`).first()
)
The await useAsyncData at the top level of <script setup> ensures the data is available during SSR — no blank pages for crawlers.
Environment Variables
Vercel's environment variable management is solid. A few things to get right:
NUXT_PUBLIC_SITE_URL or the equivalent runtime config key must be set to https://www.jamesrossjr.com (with www). Without this, the sitemap module generates URLs pointing to the wrong host, and internal links might reference non-www. Set this in Vercel's dashboard under Project Settings → Environment Variables.
Preview vs Production. Vercel creates a preview deployment for every push and pull request. If your app has environment-specific behavior (analytics, feature flags, API endpoints), separate your vars by environment. The Vercel dashboard lets you scope vars to Production, Preview, or Development.
Don't put secrets in vercel.json or anywhere committed to git. Use the dashboard or vercel env pull to sync to .env.local for local development.
The Deployment Flow
git push origin main
→ GitHub webhook triggers Vercel build
→ pnpm install
→ pnpm rebuild better-sqlite3
→ nuxt build
→ @nuxt/content indexes markdown files into SQLite
→ Nitro generates prerendered HTML for listed routes
→ Bundled output written to .output/
→ Vercel deploys .output/ to edge network
→ Production URL updated
The whole pipeline takes 35–50 seconds for this project. Most of that is the Nuxt build — prerendering 20+ routes adds meaningful time compared to a pure SSR build.
What I'd Do Differently
Use Vercel Analytics from day one. It's free, installs in two lines, and gives you real performance data. I added it late and missed early baseline data.
Set the SITE_URL environment variable before first deploy. The sitemap and robots configuration depend on it, and the default fallback (often localhost or a Vercel preview URL) will end up cached in search engines if you don't set it immediately.
Consider a separate branch for blog-only content updates. Right now, adding a blog post requires a full rebuild. Since crawlLinks: true and prerendering are involved, this is slower than a simple file write. For a higher-volume blog, you'd want either ISR (Incremental Static Regeneration) or a CMS with webhook-triggered redeploys.
The Result
The deployment pipeline is:
- Fully automated — push to
main, it ships - Fast — 35–50 second builds
- SEO-correct — prerendered HTML for crawlers
- Secure — content security headers in
vercel.json - www-canonical — permanent 301 redirect from non-www
The only manual step is setting environment variables once through the Vercel dashboard. Everything else is config as code, committed to the repo.
That's the goal: a deployment pipeline you don't have to think about, because you built it right once.