Static export vs server build: where does your API key end up?
One Next.js config, two build targets. The only difference that really matters is who can read your API key: your server, or every visitor.
Contents
This portfolio has an Ask AI chat widget that answers questions about me using Google Gemini. The widget needs an API key, and where that key lives depends entirely on how the site is built. The same codebase can produce two very different security postures from one config file.
One config, two targets
Next.js can export the site as plain files, or run it as a Node server. The config switches between them with an environment flag, so the choice is made at build time rather than baked into the code.
// next.config.js (simplified)
const isServerBuild = Boolean(process.env.SERVER_BUILD) || Boolean(process.env.VERCEL);
const isDev = process.env.NODE_ENV !== "production";
const staticExport = !isServerBuild && !isDev;
module.exports = {
trailingSlash: true,
...(staticExport ? { output: "export" } : {}),
};- Static export (default build): writes plain HTML, CSS and JS to a folder you can drop on GitHub Pages or any file host. No server exists at runtime.
- Server build (SERVER_BUILD=1, or Vercel): keeps API routes, so a /api/chat endpoint runs on a real server.
- Dev is always a server build. Otherwise next dev would 404 on the API route and the chat would only work when someone remembers the flag.
Target one: server build, the key stays home
With a server, the browser never talks to Gemini. It posts to this app's own /api/chat, the route adds the key on the server, calls Gemini, and returns only the reply. A visitor opening the network tab sees one request to my domain and nothing else.
- The key is only ever in server memory or server-side config.
- The route can enforce an origin allowlist, a per-IP rate limit and a global daily cap before any paid work happens.
- Rotating the key never touches the client bundle.
Target two: static export, nowhere private to hide
A static export has no server, so API routes are disabled and the build says so in a warning. If the chat is still wanted, the browser has to call Gemini directly, which means the browser needs the key. Anything the browser can use, a visitor can read.
With no server, there is no secret. You are choosing where to publish the key, not whether.
That is a deliberate trade, not an oversight, and it changes what protections are even possible. The origin check, rate limit and daily cap live in the API route, so none of them apply here. The caller controls the caller.
The NEXT_PUBLIC_ trap
Next.js inlines any variable prefixed with NEXT_PUBLIC_ into the JavaScript bundle at build time. If a key goes in one, it ships in plain text to every visitor, and it lands in a public repository as part of the build output. Secret scanners find that quickly and providers revoke the key automatically. I lost a key exactly this way, so the deploy now leaves that variable unset.
Making the static target survivable
If a key must be public, make it worth very little. These controls turn a leaked key from a billing incident into a nuisance.
- Restrict the key in the cloud console to the one API it needs, and nothing else.
- Add HTTP referrer restrictions so it only works from your own domains.
- Keep the project off billing, so the free tier rejects requests instead of charging for them.
- Store the key in a database document rather than in git or the bundle, so rotation is a console edit and not a redeploy.
- Be honest in the docs: that document has to be world-readable on a static site, so it moves where the key is published and does not make it secret.
A small switch that lets both coexist
The widget decides its transport at start-up. If an endpoint is configured, it posts there and the key stays server-side. If not, it falls back to the direct call. One flag, and a deployment that grows a server later gets the safer mode with no code change.
// Pick the transport once, at module scope
const endpoint = process.env.NEXT_PUBLIC_CHAT_ENDPOINT;
const send = endpoint
? (messages) => postToServer(endpoint, messages) // key never leaves the server
: (messages) => callGeminiFromBrowser(messages); // key is visible to visitorsHow I would choose
- Anything somewhere can run Node: use the server build. It is the only target where a secret can stay secret.
- Static hosting is a hard requirement: only call APIs that are safe to expose, or point the site at a separate server for the sensitive ones.
- Always assume whatever ships to the browser is public, and design the blast radius of that key before you ship it.
The takeaway is that the build target is a security decision as much as a hosting one. Decide it first, and the rest of the design, from rate limits to key rotation, follows from it.