How to Take a SvelteKit SPA + FastAPI App to Production

· min read

In the previous post we connected a SvelteKit single-page app to a FastAPI backend: two dev servers, a TypeScript client generated from the OpenAPI spec, and a page that shows data from the API. It works, but only on your laptop.

Now let’s get it ready for real users. Five things have to change before someone can log in from the other side of the world, and each one will bite you on the first deploy if you skip it. The code below is copied from FastSvelte, where this setup runs in production.

1. Addresses come from environment variables

On your laptop the frontend runs on localhost:5173 and calls the API on localhost:8000. In production the frontend might live at app.example.com and the API at api.example.com. The backend has to allow that new origin, and the frontend has to call that new API. If either address is hardcoded, it works locally and fails in production with a CORS error in the browser console.

Frontend side: one environment variable, read in one place.

// frontend/src/lib/api/fetch.ts
import { PUBLIC_API_BASE_URL } from '$env/static/public';

const fullUrl = url.startsWith('http') ? url : `${PUBLIC_API_BASE_URL}${url}`;

PUBLIC_API_BASE_URL is http://localhost:8000 in your .env and https://api.example.com in production. Nothing else in the frontend knows the API address.

Backend side: the allowed origins depend on which environment the backend is running in.

# backend/app/config/settings.py
class Settings(BaseSettings):
    environment: Environment = Environment.DEV
    ...

    @property
    def cors_origins(self) -> list[str]:
        return {
            Environment.DEV: ["http://localhost:5173", "http://localhost:4173"],
            Environment.BETA: ["https://app-beta.example.com"],
            Environment.PROD: ["https://app.example.com"],
        }.get(self.environment, [])
# backend/app/main.py
app.add_middleware(
    CORSMiddleware,
    allow_origins=settings.cors_origins,
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

Set FS_ENVIRONMENT=prod on the server and the right origins are allowed. allow_credentials=True matters for the next section: without it the browser won’t send cookies to the API.

The general rule: anything that differs between your laptop and the server is an environment variable. The settings class fails at startup if a required one is missing, which is a much better place to find out than after deploy.

A SPA and an API need a way to know who is logged in. The two common options are a JWT the frontend stores and sends as a header, or a session cookie the browser handles by itself.

Use the cookie. A token in localStorage can be read by any JavaScript running on your page, including a compromised dependency. An HTTP-only cookie can’t. The browser attaches it to every request and your frontend code never touches it.

On login, the backend creates a session row in Postgres and sets the cookie:

# backend/app/util/cookie_util.py
def set_session_cookie(response: Response, token: str) -> None:
    samesite_value = "lax" if settings.environment == Environment.DEV else "strict"
    secure_value = settings.environment != Environment.DEV
    response.set_cookie(
        key=settings.session_cookie_name,
        value=token,
        httponly=True,
        secure=secure_value,
        samesite=samesite_value,
        max_age=settings.session_cookie_max_age,
        path="/",
    )

Three flags do the work. httponly keeps JavaScript out. secure means HTTPS only, which is why it’s off in dev. samesite stops other sites from making requests with your cookie.

Every protected endpoint gets the user through one FastAPI dependency:

# backend/app/api/middleware/auth_handler.py
async def get_current_user(request, response, auth_service) -> CurrentUser:
    token = get_cookie_value(request, settings.session_cookie_name)
    if not token:
        raise Unauthorized("Missing session token")

    user = await auth_service.validate_session_token(token)
    if not user:
        raise Unauthorized("Invalid or expired session token")

    if user.session_refreshed:
        set_session_cookie(response, token)

    return user

validate_session_token hashes the token, looks up the session row, and checks expires_at. Because sessions are rows in a table, logging someone out everywhere is deleting a row. The last if handles sliding expiry: when a session is close to expiring and the user is still active, the backend extends the row and re-sends the cookie so the browser’s copy expires at the same time.

One production detail that catches people: keep the frontend and the API under the same registrable domain, like app.example.com and api.example.com. The browser treats those as the same site, so the cookie flows. Put the app on something.vercel.app and the API on something.fly.dev and the cookie won’t be sent at all.

3. One fetch wrapper for every API call

The frontend needs three things on every request: the base URL, credentials: 'include' so the cookie goes along, and something sensible when the session has expired. Do all three in one function and let the generated client use it.

// frontend/src/lib/api/fetch.ts
export const customFetch = async <T>(url: string, options: RequestInit): Promise<T> => {
	const fullUrl = url.startsWith('http') ? url : `${PUBLIC_API_BASE_URL}${url}`;

	const response = await fetch(fullUrl, {
		...options,
		credentials: 'include'
	});

	if (response.status === 401 && browser) {
		const urlPath = new URL(fullUrl).pathname;
		const isAuthEndpoint = urlPath.includes('/users/me') || urlPath.includes('/auth/login');

		if (!isAuthEndpoint) {
			authStore.clear();
			if (!window.location.pathname.startsWith(resolve(LOGIN_PATH))) {
				window.location.href = resolve(LOGIN_PATH);
			}
		}
	}

	// ... parse the body, throw on non-2xx
};

Orval is told to route every generated function through it:

// frontend/orval.config.ts
override: {
	mutator: {
		path: './src/lib/api/fetch.ts',
		name: 'customFetch'
	}
}

So a 401 anywhere in the app clears the auth state and sends the user to the login page. No page has to handle it on its own.

The auth state itself is a small class using Svelte 5 runes:

// frontend/src/lib/auth/auth.svelte.ts
class AuthStore {
	user = $state<CurrentUserProfile | null>(null);
	isLoading = $state(true);

	get isAuthenticated(): boolean {
		return this.user !== null;
	}
	...
}

export const authStore = new AuthStore();

The frontend can’t read the cookie, so on load it asks the backend who is logged in with GET /users/me. Protected pages live under a (protected) route group whose layout runs that check on mount, shows a spinner while isLoading is true, and renders the page only if isAuthenticated. Login, register, and password reset live under (auth) and skip the check.

Remember that this check is for the user’s experience, so they don’t see a flash of a page they can’t use. The backend is the security boundary. Every endpoint checks the session on its own, whatever the frontend thinks.

4. Change things without breaking things

Two kinds of change happen every week in a real app: the API changes, and the database schema changes. Both need a routine.

For the API, regenerate the client every time a route or a model changes:

cd frontend && npm run generate

FastAPI produces the OpenAPI spec from your Pydantic models. Orval turns it into typed functions. Rename a field in Python, regenerate, and TypeScript reports every place in the frontend that still uses the old name. Without this, the two sides disagree quietly and you find out from a user.

For the schema, use migrations instead of letting the ORM create tables at startup. Once real data is in the database, every change has to be a script you can run forward and, if needed, backward. FastSvelte uses Sqitch:

cd backend/db
./sqitch.sh add add_project_table -n "Add project table"
# edit deploy/, revert/, verify/ SQL files
./sqitch.sh dev deploy

The same deploy command runs against production during a release. Nothing about the schema is created by accident.

5. Deploy the two halves separately

A SvelteKit SPA and a FastAPI backend deploy as different kinds of things, and that’s fine.

The frontend is static files. npm run build produces HTML, JavaScript, and CSS that any static host serves: Vercel, Netlify, Cloudflare Pages, an S3 bucket, nginx. There’s no Node server to run in production.

The backend is a Python process. It needs a container platform or a VPS, plus a Postgres database. Railway, Fly.io, DigitalOcean App Platform, Azure Container Apps, or Docker Compose on a single server all work.

Whatever you pick, the checklist is the same:

  • PUBLIC_API_BASE_URL on the frontend points at the production API.
  • FS_ENVIRONMENT=prod on the backend, and the production frontend origin is in cors_origins.
  • Frontend and API are under the same registrable domain, so the session cookie is sent.
  • HTTPS on both. The session cookie has secure=True outside dev and won’t be set over plain HTTP.
  • Migrations run before the new backend starts serving traffic.

Get those five right and the app that worked on your laptop works on the internet.

Starting from a working setup

Everything above is already done in FastSvelte: the environment-keyed config, session cookies with sliding expiry, the fetch wrapper and generated client, Sqitch migrations, and deployment guides for Railway, Fly.io with Neon and Vercel, DigitalOcean, Azure, and self-hosting. It also includes the parts this post didn’t cover: Stripe billing, teams and roles, email verification, password reset, Google login, and an admin dashboard.

If you want the background on how the SPA and API pairing works before you buy anything, read How to use FastAPI with Svelte.

logo-light

Ship production-ready SaaS applications with FastAPI + SvelteKit. Complete authentication, payments, multi-tenancy, and admin dashboards - deploy anywhere with zero vendor lock-in.

© 2026 FastSvelte. All rights reserved.

🌼 Made with daisyUI

FASTSVELTE