31.4 Vercel: Hugo Deployment with vercel.json
Right, Vercel. You’re probably here because you’ve heard the hype about their “developer experience” and, well, it’s mostly true. They’ve taken the pain out of frontend deployment in a way that feels almost magical. But here’s the thing about magic: it works best when you know the incantation. For Hugo, that incantation is a vercel.json file. Without it, Vercel will politely shrug and try to build your site as a Node.js project, which is about as useful as a screen door on a submarine. We’re going to give it the right spellbook.
The core concept is simple: we need to tell Vercel’s build system, which they call “Builders,” exactly how to handle our static Hugo site. We do this by creating a vercel.json file in the root of our project. This little config file is the boss; it overrides all of Vercel’s automatic detection and says, “I’m in charge, do it this way.”
The Core vercel.json Configuration
Let’s start with the absolute minimum, no-frills configuration that will actually work. This is your baseline. Copy this, put it in your project root, and 90% of your problems vanish.
{
"builds": [
{
"src": "package.json",
"use": "@vercel/static-build",
"config": {
"distDir": "public"
}
}
]
}
Let’s break down why this works. The builds array defines what Vercel should do. We’re saying: “Hey Vercel, when you see a package.json file (which is just a convenient trigger, Hugo doesn’t need it), use the @vercel/static-build Builder.” This Builder is brilliantly generic; it doesn’t assume a framework. It just runs npm run build if it finds a package.json. Our job is to give it a package.json with a build script that runs hugo. And the config.distDir tells it where to find the finished static files after the build completes. Simple, effective, genius.
The Supporting package.json Cast Member
Your vercel.json is useless without its sidekick, package.json. You don’t need a complex Node.js project, you just need the bare minimum to trigger the build process. Create this file if you don’t have one.
{
"scripts": {
"build": "hugo --gc --minify"
},
"devDependencies": {
"hugo-bin": "^0.102.0"
}
}
The build script is what the @vercel/static-build Builder will execute. I’ve added the --gc (garbage collection) and --minify flags because they’re good practice for production builds, but hugo alone would work. Now, the devDependencies part is Vercel’s secret sauce. They install your devDependencies automatically before running the build script. By including hugo-bin, we’re ensuring the hugo command is available in their build environment. It’s a clean, version-controlled way to manage your Hugo binary without messing with system packages.
Handling Hugo’s Configuration Nuances
Here’s where we get into the weeds. Hugo can be configured with a config.toml, config.yaml, or a config directory. Vercel, being a platform, needs environment-specific settings, especially for your baseURL. You could hardcode it, but don’t. Use Vercel’s environment variables. The smart way is to use Hugo’s built-in environment configuration. Create a config directory and add these files:
config/_default/config.toml (your base settings)
baseURL = "/"
languageCode = "en-us"
title = "My Incredible Hugo Site"
config/production/config.toml (overrides for production on Vercel)
baseURL = "https://${{PRODUCTION_DOMAIN}}/"
Then, in your Vercel project dashboard, you need to set the environment variable PRODUCTION_DOMAIN to your actual domain (e.g., my-site.vercel.app or your custom domain). Hugo automatically uses the production config when HUGO_ENV is set to production, which Vercel does by default. This keeps your configuration clean and dynamic.
The “I Use a Theme” Caveat
If you’re using a theme as a git submodule (which you should be), Vercel’s default clone depth can bite you. They do a shallow clone, which often fails to pull in the submodule properly. The fix is to tell Vercel to do a full, deep clone. Add this to your vercel.json:
{
"builds": [ ... ], // your existing builds config
"functions": { ... }, // any functions config you might have
"installCommand": "git submodule update --init --recursive && npm install"
}
The installCommand overrides the default npm install. Now, it ensures your submodules are properly initialized and updated before trying to install any Node dependencies. It’s a crucial step that prevents those head-scratching “build failed because my theme directory is empty” errors.