Environment Variables for Static Sites
How to use build-time env vars in Nuxt and other static generators without leaking secrets or breaking CI.
Environment Variables for Static Sites
Static sites do not have a server to read secrets at request time. Every process.env value you rely on is baked in at build time—which is both a feature and a footgun if you treat client env like a private vault.
Public vs private
Anything prefixed for the client bundle is visible in the shipped JavaScript. Treat those values as public: analytics IDs, public API base URLs, feature flags you are fine exposing.
Never put API keys, tokens, or private credentials in client-side env vars. If a value must stay secret, keep it on a server or edge function—not in a static export. “Obfuscated” is not “secret.” Anyone can open DevTools, search the bundle, or read the network tab.
A practical split I use:
| Kind of value | Where it belongs |
|---|---|
| Site URL, public CDN base | Client / public runtime config |
| Analytics measurement ID | Client / public (expected to be visible) |
| Private API token | Server-only env or edge secret |
| Webhook signing secret | Server-only; never NUXT_PUBLIC_* |
When a third-party dashboard shows you an “API key,” check whether it is meant for browsers. Many services give you a publishable key (safe-ish in the client) and a secret key (server only). Mixing those up is one of the most common static-site leaks I see in reviews.
If you are tempted to hide a key “just for this weekend prototype,” assume the prototype will ship. Put the secret behind a function from day one, or use a throwaway key you can revoke without drama.
Nuxt runtime config
Nuxt separates public and private config so you do not have to invent your own convention:
export default defineNuxtConfig({
runtimeConfig: {
apiSecret: process.env.API_SECRET, // server-only
public: {
siteUrl: process.env.NUXT_PUBLIC_SITE_URL
}
}
})
In components and composables, read public values through useRuntimeConfig().public. Private keys stay on the server side of Nitro—pages, server routes, or middleware that never ship to the browser.
For a Cloudflare Pages deploy with Nitro, keep secrets in server-only runtime config (or Pages project env vars) and put only public values in the client bundle. Pages will inject env at build/runtime according to how you configure the project; your job is to make sure the names match what nuxt.config expects.
A few habits that prevent surprises:
- Prefer
NUXT_PUBLIC_*for values that must be available at build for prerendered HTML - Avoid reading
process.envdirectly inside client components; go through runtime config - Fail loudly in CI if a required public var is missing—empty strings become mysterious broken links later
Example of a small server route that uses a private secret without exposing it:
// server/api/contact.post.ts
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
const body = await readBody(event)
// config.apiSecret never appears in the client bundle
await $fetch('https://api.example.com/messages', {
method: 'POST',
headers: { Authorization: `Bearer ${config.apiSecret}` },
body
})
return { ok: true }
})
Static output can still include API routes when your host supports them. The static pages stay public; the secret stays on the edge.
Keep CI and local in sync
Document required variables in the README. Use a .env.example with placeholder values so contributors know what to set—without committing real secrets:
# .env.example
NUXT_PUBLIC_SITE_URL=https://example.com
API_SECRET=replace-me
Add .env to .gitignore. When a build fails only in CI, the first check is whether the workflow or host dashboard defines the same env vars your local .env has. The second check is whether the names match exactly—including the NUXT_PUBLIC_ prefix.
I keep a short checklist next to deploy docs:
- Local
.envworks fornpm run devandnpm run build - CI / Pages project has the same keys for production
- Preview environments get safe defaults (often a staging site URL, never production secrets you cannot rotate)
- Someone new can copy
.env.exampleand know which values are required vs optional
Also watch for “works in preview, broken in production” caused by env set only on one environment. Cloudflare Pages and similar hosts let you scope variables per environment—use that deliberately instead of assuming one global set.
If a value changes how prerendered HTML looks (canonical URLs, feature flags baked into markup), rebuild after changing it. Static sites do not pick up new env at request time for HTML that was already generated.
One more footgun: committing a real .env “just this once” to unblock a teammate. Rotate anything that touched git history, even briefly, and prefer a password manager or host dashboard invite instead. Secrets in commits are forever unless you rewrite history—and rewriting shared history is worse than the original shortcut.
Wrap-up
Static hosting rewards boring env hygiene: public values only in the bundle, secrets off the client entirely, and the same variable names in local dev and CI. Get that right once and deploys stop being guesswork—and you avoid the worst kind of incident report: a secret that was never supposed to be public in the first place.