Skip to content

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.

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.

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 only
import { 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.ts
export 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.

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.

To ship one image to every environment, pick one of these two.

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:stable
COPY 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/, ""),
},
},
},

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/sh
set -eu
cat > /usr/share/nginx/html/config.json <<JSON
{ "apiBaseUrl": "${API_BASE_URL:?set API_BASE_URL}" }
JSON
COPY --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.

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.