Point the client at your backend
The backend URL is the baseUrl you pass to createClient (see the fetch client). seampact never writes it into the generated code: each environment gives its own.
A browser build bakes in the variables it reads (VITE_, NEXT_PUBLIC_, EXPO_PUBLIC_). To change the URL without a rebuild, use runtime config, a config.json, or a same-origin proxy.
Create each browser client once and reuse it. Two apis on two backends: two variables, two clients.
Where the URL comes from
Section titled “Where the URL comes from”| App | Read the URL from | Fixed when |
|---|---|---|
| Vite (React, Vue, Svelte) | import.meta.env.VITE_API_URL |
build |
| Next.js, browser | process.env.NEXT_PUBLIC_API_URL |
next build |
| Next.js, server | process.env.API_URL |
each request |
| Nuxt | useRuntimeConfig().public.apiUrl, set by NUXT_PUBLIC_API_URL |
server start |
| SvelteKit 2 | env.PUBLIC_API_URL from $env/dynamic/public |
server start |
| SvelteKit 3 | a public: true variable of defineEnvVars |
server start |
| Angular | /config.json fetched in provideAppInitializer (see a config file); environment.apiUrl is fixed at build |
container start |
| Expo | process.env.EXPO_PUBLIC_API_URL, written exactly like this |
bundle |
| Node | process.env.NOTES_API_URL |
process start |
A build-time value stays in the image you promote from staging to production. To change it without a rebuild, see Docker below.
Docs: Vite, Next.js, Nuxt, SvelteKit, Expo.
import { createClient } from "./__generated__/client/notes/index.js";
const client = createClient({ baseUrl: import.meta.env.VITE_API_URL });Put VITE_API_URL=http://localhost:3000 in .env.development. Only VITE_ variables reach the browser.
Next.js
Section titled “Next.js”Server code reads any variable at runtime, without the NEXT_PUBLIC_ prefix, so it stays out of the browser: route handlers, server components, server actions and proxy. Give it an absolute internal URL such as http://api:8080. The simplest setup: the browser calls the app’s own route handlers, which call the backend, so no public variable is needed.
// lib/notes.ts, imported by server code onlyimport { createClient } from "./__generated__/client/notes/index.js";
export function notesClient() { const baseUrl = process.env.API_URL;
if (baseUrl === undefined) throw new Error("Set API_URL.");
return createClient({ baseUrl });}
// app/api/notes/route.tsexport async function GET() { const reply = await notesClient().listNotes({});
if (!reply.ok) return new Response(null, { status: reply.status });
return Response.json(reply.body);}Route handlers run on each request. A server component on a prerendered page reads the variable at build: call await connection() first.
When the browser must call the backend itself, NEXT_PUBLIC_API_URL is fixed by next build. To choose it at runtime, read the value in a server component and pass it down.
Server-side rendering
Section titled “Server-side rendering”A relative baseUrl such as /api works in the browser, not in Node: there is no page origin to resolve it against. Code that runs on the server needs an absolute internal URL, such as http://api:8080.
Write process.env.EXPO_PUBLIC_API_URL exactly like this: Expo does not replace a destructured or bracketed read. A URL that changes after release needs a remote config fetched at start. The client setup on a phone: Expo and React Native.
Docker: one image for every environment
Section titled “Docker: one image for every environment”To ship one image to every environment, pick one of these two.
Same origin behind a proxy (simplest)
Section titled “Same origin behind a proxy (simplest)”The app calls /api, and the proxy in front of it forwards to the backend without the /api prefix:
const client = createClient({ baseUrl: "/api" });With the official nginx image, put this in nginx/default.conf.template:
server { listen 80; root /usr/share/nginx/html;
location /api/ { proxy_pass ${API_UPSTREAM}/; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; }
location = /index.html { add_header Cache-Control "no-cache"; }
location / { try_files $uri /index.html; }}FROM nginx:stableCOPY nginx/default.conf.template /etc/nginx/templates/COPY dist/ /usr/share/nginx/html/Run it with -e API_UPSTREAM=http://backend:3000. At start, the image replaces ${API_UPSTREAM} and keeps nginx’s own $host. The trailing / of proxy_pass removes /api. index.html is revalidated on each visit, so a new deploy shows at once. (nginx image docs)
The browser and the api share one origin, so there is no CORS to set up. Paging works too: the client reads only the cursor or page number from a page link, then calls its own baseUrl, /api included.
In development, the Vite dev server does the same:
server: { proxy: { "/api": { target: "http://localhost:3000", changeOrigin: true, rewrite: (path) => path.replace(/^\/api/, ""), }, },},A config file written at start
Section titled “A config file written at start”When the api lives on another origin, write a config.json from the environment when the container starts. Add an executable script to the image, nginx/40-write-config.sh:
#!/bin/shset -eu
cat > /usr/share/nginx/html/config.json <<JSON{ "apiBaseUrl": "${API_BASE_URL:?set API_BASE_URL}" }JSONCOPY --chmod=755 nginx/40-write-config.sh /docker-entrypoint.d/The image runs every executable script of /docker-entrypoint.d/ before nginx starts, and only when the container command is nginx; a missing API_BASE_URL stops the container. Serve the file uncached, next to the index.html block:
location = /config.json { add_header Cache-Control "no-store";}Read it once, before the app creates its client (in Angular, inside provideAppInitializer):
const response = await fetch("/config.json", { cache: "no-store" });const config: { apiBaseUrl: string } = await response.json();const client = createClient({ baseUrl: config.apiBaseUrl });Anyone can read config.json: put only public values in it, never a secret, and write only the keys the app needs.
Another origin and CORS
Section titled “Another origin and CORS”When the browser calls another origin, the server must allow it (CORS). seampact does not write this for you: use your framework’s own tool, and keep the CORS layer before the generated routes so a preflight (OPTIONS) reaches it.
| Server | CORS tool |
|---|---|
| NestJS | app.enableCors() |
| Express | the cors package |
| Fastify | @fastify/cors |
| Hono | hono/cors |
| Spring Boot | CorsConfiguration |
| Laravel | config/cors.php |
| Symfony | NelmioCorsBundle |
| FastAPI | CORSMiddleware, see FastAPI |
| Django REST framework, Django Ninja | django-cors-headers, see Ninja |
A same-origin proxy, as above, needs none of it.
React, Vue, Angular or Expo and React Native: create the client there.