# FastSvelte Documentation > FastSvelte is a FastAPI (Python) + SvelteKit starter kit: auth, payments, and multi-tenancy, plus AI-ready usage metering, per-plan credit allowances, and top-ups. Self-hosted, full source code, no platform lock-in. Includes generated TypeScript API clients via Orval, admin and user dashboards, and Docker Compose for local development. Supports both B2C and B2B (multi-tenant with organizations, roles, and invitations) modes. FastSvelte is a FastAPI (Python) + SvelteKit starter kit: auth, payments, and multi-tenancy, plus AI-ready usage metering, per-plan credit allowances, and top-ups. Self-hosted, full source code, no platform lock-in. Includes generated TypeScript API clients via Orval, admin and user dashboards, and Docker Compose for local development. Supports both B2C and B2B (multi-tenant with organizations, roles, and invitations) modes. For the full documentation content concatenated into a single file, see [llms-full.txt](https://docs.fastsvelte.dev/llms-full.txt). # Get Started # Quick Start [FastSvelte](https://fastsvelte.dev) is a fullstack SaaS starter kit built with **FastAPI** (Python) and **SvelteKit** (TypeScript). Get it running locally in a few minutes, then browse [Features](https://docs.fastsvelte.dev/features/authentication/index.md) for everything that's included. The interactive `init.py` script handles setup end to end. Clone the repo, run `init.py`, then create an admin account and start the servers: ```sh # 1. Clone, then drop the template remote git clone https://github.com/harunzafer/fastsvelte.git my-project cd my-project && git remote remove origin # 2. Interactive setup (--dry-run to preview what it will do) uv run init.py # 3. Create a system admin, then start the backend cd backend uv run scripts/create_admin.py uv run uvicorn app.main:app --reload # 4. In a new terminal, start the frontend cd frontend && npm run dev ``` Prerequisites You'll need [uv](https://docs.astral.sh/uv/#installation) to run `init.py` in the first place, plus **Node.js 22+** and **Docker** (running). `init.py` checks those two first and exits with install instructions if either is missing, so once `uv` is in place you can just run it and fix what it flags. Open **[localhost:5173](http://localhost:5173)** and log in with the admin you created. The backend and interactive API docs are at [localhost:8000/docs](http://localhost:8000/docs); the landing template runs at [localhost:5174](http://localhost:5174). ## Next steps - **[Guides](https://docs.fastsvelte.dev/guides/project-setup/index.md)**: set up your project, develop, and ship - **[Features](https://docs.fastsvelte.dev/features/authentication/index.md)**: auth, billing, AI, multi-tenancy, and more - **[Architecture](https://docs.fastsvelte.dev/reference/architecture/index.md)** · **[Deployment](https://docs.fastsvelte.dev/deployment/index.md)** - **[How to use FastAPI with Svelte](https://fastsvelte.dev/fastapi-svelte)**: background on the SPA + API pairing (CORS, session cookies, repo layout) # Guides # Project Setup After this guide you'll understand what `init.py` configured, where configuration lives, and how to onboard a teammate. ## What `init.py` does You run `init.py` once during [Quick Start](https://docs.fastsvelte.dev/index.md). It: - prompts for your app name, mode (`b2c` / `b2b`), and database; - generates the `.env` files across `backend/`, `frontend/`, `landing/`, and `backend/db/`; - starts Docker PostgreSQL and runs migrations (with seed data); - installs backend (`uv`) and frontend/landing (`npm`) dependencies; - generates the type-safe API client (see [Type-Safe API Client](https://docs.fastsvelte.dev/guides/orval/index.md)). Pass `--dry-run` to preview without making changes. No email is sent in development The email provider defaults to `stub`. Verification and invitation links appear as `[STUB EMAIL]` blocks in the backend logs instead (see [Transactional Email](https://docs.fastsvelte.dev/features/email/index.md)). Tip Once setup works, you can delete `init.py`. It's only needed once. ## Onboarding teammates **Only the first developer runs `init.py`.** Everyone else starts from your committed, pre-configured repository. 1. **Push to your own repository:** ```bash git remote add origin git@github.com:your-org/your-project.git git push -u origin main ``` 1. **Teammates clone and configure:** - Clone your repository (not the FastSvelte template). - Install the [prerequisites](https://docs.fastsvelte.dev/index.md) (uv, Node.js 22+, Docker). - Create `.env` files by copying each `.env.example` (`backend/`, `frontend/`, `landing/`, `backend/db/`). - Start the database: `docker compose -f backend/docker-compose.yml up db -d` - Run migrations: `cd backend/db && ./sqitch.sh dev deploy` - Install deps: `uv sync` in `backend/`, `npm install` in `frontend/` and `landing/` - Create a local admin: `cd backend && uv run scripts/create_admin.py` Never commit `.env` files They contain secrets and are gitignored. Share values through a secure channel (1Password, Doppler, a shared secrets manager). See [Configuration](https://docs.fastsvelte.dev/reference/configuration/index.md) for the full variable reference. # Development Workflow After this guide you can make a change across the stack and run the app. Work in the **DB → Backend → Frontend** order so the API client regenerates against an up-to-date backend. ## Database changes ```bash cd backend/db ./sqitch.sh add feature_name -n "Description" # Edit the generated deploy/, revert/, verify/ SQL files ./sqitch.sh dev deploy ``` See [Database](https://docs.fastsvelte.dev/features/database/index.md) for the migration model. ## Backend changes ```bash cd backend uv add package_name # add a dependency (--dev for dev-only) uv run ruff check . # lint uv run pytest # test uv run uvicorn app.main:app --reload # run (auto-reloads on change) ``` ## Frontend changes After backend API changes, regenerate the typed client (see [Type-Safe API Client](https://docs.fastsvelte.dev/guides/orval/index.md)): ```bash cd frontend npm run generate # regenerate the API client (backend must be running) npm run format # Prettier npm run check # type checking npm run test # tests (also test:unit / test:e2e) npm run dev # dev server (hot reload) ``` ## Continuous integration FastSvelte comes with a GitHub Actions workflow (`.github/workflows/ci.yml`) that runs on every push and pull request. It checks formatting, lint, and types, and makes sure the apps build and the backend tests pass. It's yours to extend or adjust as your project grows. # Adding a Feature This guide adds a complete **Projects** feature: a database table, a CRUD API, and a page in the app. Every step mirrors the shipped **Notes** feature, so you can open the real file next to each snippet and see the same pattern with its production trimmings. Work in **DB → backend → frontend** order so the generated API client stays in sync (see [Development Workflow](https://docs.fastsvelte.dev/guides/development-workflow/index.md)). About the schema name The SQL examples use `myapp` as the schema. Use your own: the value of `FS_DB_SCHEMA` in `backend/.env`, chosen when you ran `init.py`. ## 1. Create the migration ```bash cd backend/db ./sqitch.sh add add_project -n "Add project table" ``` This creates three files: `deploy/add_project.sql` (how to apply the change), `revert/add_project.sql` (how to undo it), and `verify/add_project.sql` (how to prove it worked). Fill in `deploy/add_project.sql` below the generated header: ```sql BEGIN; CREATE TABLE myapp.project ( id SERIAL PRIMARY KEY, name TEXT NOT NULL, description TEXT, organization_id INTEGER NOT NULL REFERENCES myapp.organization(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES myapp."user"(id) ON DELETE CASCADE, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX idx_project_organization ON myapp.project(organization_id); COMMIT; ``` `organization_id` is what keeps tenants isolated: every query in the repository will filter on it. The index backs those queries. `revert/add_project.sql`: ```sql BEGIN; DROP TABLE IF EXISTS myapp.project; COMMIT; ``` `verify/add_project.sql` proves the table exists with the expected columns, the same way the kit's own verify scripts do: ```sql BEGIN; SELECT id, name, description, organization_id, user_id, created_at, updated_at FROM myapp.project WHERE false; ROLLBACK; ``` Deploy it: ```bash ./sqitch.sh dev deploy ``` You will see the change apply: ```text + add_project .. ok ``` ## 2. Define the models Create `backend/app/model/project_model.py`. Model files end in `_model.py`, and timestamped entities extend the shared `Timestamped` base instead of redeclaring the columns: ```python from pydantic import BaseModel from app.model.common import Timestamped class Project(Timestamped): id: int organization_id: int user_id: int name: str description: str | None = None class CreateProjectRequest(BaseModel): name: str description: str | None = None class UpdateProjectRequest(BaseModel): name: str | None = None description: str | None = None class ProjectResponse(Timestamped): id: int name: str description: str | None = None ``` `Project` is the internal entity, one to one with a table row. `ProjectResponse` is what the API returns; it leaves out `organization_id` and `user_id` because the caller's session already determines both. ## 3. Add the repository Create `backend/app/data/repo/project_repo.py`. Repositories are the only layer that touches SQL. `self.schema` comes from `BaseRepo` (it reads `FS_DB_SCHEMA`), so the code itself stays schema-agnostic: ```python from app.data.repo.base_repo import BaseRepo from app.model.project_model import Project class ProjectRepo(BaseRepo): async def create_project( self, organization_id: int, user_id: int, name: str, description: str | None ) -> Project: query = f""" INSERT INTO {self.schema}.project (organization_id, user_id, name, description) VALUES ($1, $2, $3, $4) RETURNING id, organization_id, user_id, name, description, created_at, updated_at """ row = await self.fetch_one(query, organization_id, user_id, name, description) return Project(**row) async def get_project_by_id(self, project_id: int, organization_id: int) -> Project | None: query = f""" SELECT id, organization_id, user_id, name, description, created_at, updated_at FROM {self.schema}.project WHERE id = $1 AND organization_id = $2 """ row = await self.fetch_one(query, project_id, organization_id) return Project(**row) if row else None async def list_projects(self, organization_id: int) -> list[Project]: query = f""" SELECT id, organization_id, user_id, name, description, created_at, updated_at FROM {self.schema}.project WHERE organization_id = $1 ORDER BY created_at DESC """ rows = await self.fetch_all(query, organization_id) return [Project(**row) for row in rows] async def update_project( self, project_id: int, organization_id: int, name: str | None, description: str | None ) -> Project | None: query = f""" UPDATE {self.schema}.project SET name = COALESCE($3, name), description = COALESCE($4, description), updated_at = now() WHERE id = $1 AND organization_id = $2 RETURNING id, organization_id, user_id, name, description, created_at, updated_at """ row = await self.fetch_one(query, project_id, organization_id, name, description) return Project(**row) if row else None async def delete_project(self, project_id: int, organization_id: int) -> None: query = f"DELETE FROM {self.schema}.project WHERE id = $1 AND organization_id = $2" await self.execute(query, project_id, organization_id) ``` Every read and write carries `organization_id` in the `WHERE` clause. A user can never reach another organization's rows, even with a guessed id, because the filter is part of the query itself rather than a check bolted on afterwards. ## 4. Add the service Create `backend/app/service/project_service.py`. The service owns business logic; for plain CRUD it stays thin, and that's fine. It exists so that when rules arrive (quotas, notifications, cross-entity checks) they have a home that isn't a route handler: ```python from app.data.repo.project_repo import ProjectRepo from app.model.project_model import CreateProjectRequest, Project, UpdateProjectRequest class ProjectService: def __init__(self, project_repo: ProjectRepo): self.project_repo = project_repo async def create_project( self, organization_id: int, user_id: int, data: CreateProjectRequest ) -> Project: return await self.project_repo.create_project( organization_id, user_id, data.name, data.description ) async def list_projects(self, organization_id: int) -> list[Project]: return await self.project_repo.list_projects(organization_id) async def get_project(self, project_id: int, organization_id: int) -> Project | None: return await self.project_repo.get_project_by_id(project_id, organization_id) async def update_project( self, project_id: int, organization_id: int, data: UpdateProjectRequest ) -> Project | None: return await self.project_repo.update_project( project_id, organization_id, data.name, data.description ) async def delete_project(self, project_id: int, organization_id: int) -> None: await self.project_repo.delete_project(project_id, organization_id) ``` ## 5. Add the routes Create `backend/app/api/route/project_route.py`: ```python from dependency_injector.wiring import Provide, inject from fastapi import APIRouter, Depends from app.api.middleware.auth_handler import min_role_required from app.config.container import Container from app.exception.common_exception import ResourceNotFound from app.model.project_model import CreateProjectRequest, ProjectResponse, UpdateProjectRequest from app.model.role_model import Role from app.model.user_model import CurrentUser from app.service.project_service import ProjectService router = APIRouter() @router.post("", response_model=ProjectResponse, operation_id="createProject") @inject async def create_project( data: CreateProjectRequest, user: CurrentUser = Depends(min_role_required(Role.MEMBER)), project_service: ProjectService = Depends(Provide[Container.project_service]), ): project = await project_service.create_project(user.organization_id, user.id, data) return ProjectResponse.model_validate(project.model_dump()) @router.get("", response_model=list[ProjectResponse], operation_id="listProjects") @inject async def list_projects( user: CurrentUser = Depends(min_role_required(Role.READONLY)), project_service: ProjectService = Depends(Provide[Container.project_service]), ): projects = await project_service.list_projects(user.organization_id) return [ProjectResponse.model_validate(p.model_dump()) for p in projects] @router.get("/{project_id}", response_model=ProjectResponse, operation_id="getProject") @inject async def get_project( project_id: int, user: CurrentUser = Depends(min_role_required(Role.READONLY)), project_service: ProjectService = Depends(Provide[Container.project_service]), ): project = await project_service.get_project(project_id, user.organization_id) if not project: raise ResourceNotFound("project", project_id) return ProjectResponse.model_validate(project.model_dump()) @router.put("/{project_id}", response_model=ProjectResponse, operation_id="updateProject") @inject async def update_project( project_id: int, data: UpdateProjectRequest, user: CurrentUser = Depends(min_role_required(Role.MEMBER)), project_service: ProjectService = Depends(Provide[Container.project_service]), ): project = await project_service.update_project(project_id, user.organization_id, data) if not project: raise ResourceNotFound("project", project_id) return ProjectResponse.model_validate(project.model_dump()) @router.delete("/{project_id}", status_code=204, operation_id="deleteProject") @inject async def delete_project( project_id: int, user: CurrentUser = Depends(min_role_required(Role.MEMBER)), project_service: ProjectService = Depends(Provide[Container.project_service]), ): project = await project_service.get_project(project_id, user.organization_id) if not project: raise ResourceNotFound("project", project_id) await project_service.delete_project(project_id, user.organization_id) ``` Three conventions to notice, all copied from the shipped `note_route.py`: - **Reads take `Role.READONLY`, writes take `Role.MEMBER`.** A read-only user can browse but not change anything. - **`operation_id` becomes the TypeScript function name** when the API client is generated (`listProjects()` in the frontend). See [Type-Safe API Client](https://docs.fastsvelte.dev/guides/orval/index.md). - **Missing rows raise `ResourceNotFound`**, which the exception middleware turns into a clean 404. Services and repositories never touch HTTP status codes. Want a per-plan limit on projects? The notes feature caps creation with a plan quota (`max_notes`). To do the same for projects, follow [Metering a Custom Feature](https://docs.fastsvelte.dev/guides/metering-a-custom-feature/index.md). ## 6. Wire it into the container Register the new classes in `backend/app/config/container.py`. There are **three** additions, and the third is the one that's easy to forget. Add the imports: ```python from app.data.repo.project_repo import ProjectRepo from app.service.project_service import ProjectService ``` Add the providers, next to the existing repository and service registrations. Everything in the container is a `Singleton`: repositories and services hold no per-request state, so one instance serves the whole app: ```python project_repo = providers.Singleton(ProjectRepo, db_config=db_config) project_service = providers.Singleton( ProjectService, project_repo=project_repo, ) ``` Add the route module to the `wiring_config` list in the same file: ```python wiring_config = containers.WiringConfiguration( modules=[ # ...existing modules... "app.api.route.project_route", ] ) ``` Skipping the wiring entry breaks the routes at runtime `@inject` only works in modules listed in `wiring_config`. Leave `app.api.route.project_route` out and the app still starts, but every `/projects` request fails, because `Provide[Container.project_service]` is never replaced with a real service. ## 7. Register the router In `backend/app/api/router.py`, import the router and add it inside `include_all_routers()`: ```python from app.api.route.project_route import router as project_router app.include_router(project_router, prefix="/projects", tags=["Projects"]) ``` The `Projects` tag groups the endpoints in the OpenAPI spec, and the generated client uses it to name the module (`projects.ts`). ## 8. Run it: test the API Start the backend: ```bash cd backend uv run uvicorn app.main:app --reload ``` Open [localhost:8000/docs](http://localhost:8000/docs). You will see a **Projects** section with the five endpoints. If it's missing, revisit step 7; if the endpoints are there but return 500, revisit step 6. The kit ships `.http` test files under `backend/http/` for the [REST Client](https://marketplace.visualstudio.com/items?itemName=humao.rest-client) VSCode extension. Add `backend/http/22_projects.http`: ```http ### Projects API ### Log in first via 01_auth.http; the session cookie persists across files. @baseUrl = {{$dotenv BASE_URL}} ### Create project POST {{baseUrl}}/projects Content-Type: application/json { "name": "My first project", "description": "Testing the new feature" } ### List projects GET {{baseUrl}}/projects ### Get one # @prompt projectId GET {{baseUrl}}/projects/{{projectId}} ### Update # @prompt projectId PUT {{baseUrl}}/projects/{{projectId}} Content-Type: application/json { "name": "Renamed project" } ### Delete # @prompt projectId DELETE {{baseUrl}}/projects/{{projectId}} ### Missing project returns 404 GET {{baseUrl}}/projects/99999 ``` Open `01_auth.http`, run the login request, then run "Create project". You will see the JSON response: ```json { "id": 1, "name": "My first project", "description": "Testing the new feature", "created_at": "2026-08-01T12:00:00.000000+00:00", "updated_at": "2026-08-01T12:00:00.000000+00:00" } ``` Testing with curl instead Log in once and reuse the session cookie: ```bash curl -X POST http://localhost:8000/auth/login \ -H "Content-Type: application/json" \ -d '{"email": "you@example.com", "password": "your-password"}' \ -c cookies.txt curl -X POST http://localhost:8000/projects \ -H "Content-Type: application/json" \ -d '{"name": "My first project"}' \ -b cookies.txt curl http://localhost:8000/projects -b cookies.txt ``` ## 9. Regenerate the API client With the backend still running: ```bash cd frontend npm run generate ``` You will see a new `projects.ts` module in `frontend/src/lib/api/gen/` exporting `createProject`, `listProjects`, `getProject`, `updateProject`, and `deleteProject`, fully typed from the OpenAPI spec. ## 10. Add the invalidation key The frontend refreshes data by re-running load functions, and load functions are matched by named scopes. Add a scope for projects in `frontend/src/lib/invalidation-keys.ts`: ```ts export const KEYS = { // ...existing keys... /** The organization's projects. Claimed by the projects list. */ projects: 'app:projects' } as const; ``` ## 11. Build the page Data loading follows the kit's load-function pattern: the page's `+page.ts` fetches, the component renders. No `onMount`, no hand-rolled `loading` flags. The shipped notes page is the annotated reference for everything in this step; open `src/routes/(protected)/notes/+page.ts` and `+page.svelte` alongside it. Create `frontend/src/routes/(protected)/projects/+page.ts`: ```ts import { listProjects } from '$lib/api/gen/projects'; import { KEYS } from '$lib/invalidation-keys'; import type { PageLoad } from './$types'; export const load: PageLoad = async ({ depends }) => { // Claims the scope that invalidate(KEYS.projects) matches. depends(KEYS.projects); // No await: the page receives a promise and renders a skeleton while it // resolves. Errors land in the {:catch} branch of the {#await} block. return { projects: listProjects().then((projects) => projects ?? []) }; }; ``` Create `frontend/src/routes/(protected)/projects/+page.svelte`: ```text
{#await data.projects}
{#each Array(4) as _, i (i)}
{/each}
{:then projects} {#if projects.length === 0}

No projects yet. Create the first one.

{:else}
{#each projects as project (project.id)}

{project.name}

{#if project.description}

{project.description}

{/if}
{/each}
{/if} {:catch}
Failed to load projects. Please try again.
{/await} ``` The `confirm()` dialog keeps this example short; the notes page shows the same flow with a proper modal. Create the form at `frontend/src/routes/(protected)/projects/new/+page.svelte`, using the kit's `createFormValidation` helper the same way the notes create modal does: ```text
{#if submitError}
{submitError}
{/if}
{#if form.errors.name} {form.errors.name} {/if}
``` There is no `invalidate()` after create: navigating to `/projects` runs its load function anyway. ## 12. Add it to the sidebar Navigation lives in `frontend/src/routes/(protected)/menu.ts` as a typed array. Add an entry: ```ts { id: 'projects', icon: 'lucide--folder', label: 'Projects', url: resolve('/projects'), minRole: 'readonly' }, ``` `minRole: 'readonly'` matches the routes: read-only users can open the list, and the write buttons are the backend's problem to refuse. ## 13. Run it end to end Start the frontend (with the backend still running): ```bash cd frontend npm run dev ``` Open [localhost:5173/projects](http://localhost:5173/projects). You will see the skeleton flash, then the project you created in step 8. Create another through the form, delete one from the list, and watch the list refresh without a page reload. ## Recap - Write the migration (`deploy`, `revert`, `verify`) and deploy it with `./sqitch.sh dev deploy`. - Add the four backend pieces: `project_model.py`, `project_repo.py`, `project_service.py`, `project_route.py`. - Register all of it in `container.py`: two `Singleton` providers **and** the `wiring_config` entry. - Include the router in `router.py` and confirm it in `/docs`. - Regenerate the typed client with `npm run generate`. - Add an invalidation key, a `+page.ts` load that returns an un-awaited promise, a page that renders it with `{#await}`, and a menu entry. The same thirteen steps add any entity to FastSvelte. When your feature outgrows plain CRUD, the notes feature shows where each addition goes: quotas in the route via the usage service, AI actions via the copilot pattern, business rules in the service. # Metering a Custom Feature After this guide you can put any action behind a per-plan usage limit and, optionally, let credit top-ups extend it the way AI tokens do. See [Plans & Usage](https://docs.fastsvelte.dev/features/plans-and-usage/index.md) for the engine this builds on. ## 1. Declare the feature Add a `FeatureKey` member and a `PlanFeatures` field in `backend/app/model/plan_model.py`: ```python class FeatureKey(str, Enum): MAX_NOTES = "max_notes" TOKEN_LIMIT = "token_limit" ENABLE_AI = "enable_ai" MAX_PROJECTS = "max_projects" # new class PlanFeatures(BaseModel): ... max_projects: int = Field( default=0, title="Max Projects", json_schema_extra={"kind": "quota", "allows_unlimited": True}, ) ``` The field is the whole declaration. The **default** is what an organization gets when a plan's JSON omits the key, so 0 keeps the feature blocked until you set a limit. `title` is the label the admin form and the billing page display, and `kind: "quota"` gives the feature a used/limit row with a meter on the billing page. `test_plan_features.py` pins `FeatureKey` to the model's fields, so forgetting one half fails the test run. Info The frontends read this declaration from `GET /plan/feature-schema` and the effective values with usage from `GET /usage/features`. That is why the billing page row and the admin form warnings appear with no frontend change. Note For a boolean feature, declare a `bool` field with `kind: "flag"` and enforce it in the endpoint that serves the feature. `enable_ai`, checked by the copilot endpoints via `ensure_ai_in_plan`, is the shipped example. ## 2. Set limits per plan Add `max_projects` to each plan's `features` map, either from the admin Plans page or in seed data (see [Plans & Usage](https://docs.fastsvelte.dev/features/plans-and-usage/#configuring-limits)). Plans that do not have the key yet keep working: the admin form warns that `max_projects` is not set and will default to 0. ## 3. Enforce the limit Wrap the action with the generic usage service. Check the quota before, record usage after: ```python if not await usage_service.check_quota_for(org_id, FeatureKey.MAX_PROJECTS, 1): raise QuotaExceeded(FeatureKey.MAX_PROJECTS) project = await project_service.create(...) await usage_service.update_usage(org_id, FeatureKey.MAX_PROJECTS, 1) ``` That's all it takes to meter a feature against the plan limit, reset each billing period. ## 4. (Optional) Let credits top it up Today only AI tokens fall back to the [credit balance](https://docs.fastsvelte.dev/features/ai-billing/index.md). To let credits extend another feature, mirror what `AiUsageBillingService` does: when the per-period limit is exhausted, check and debit the org's credit balance before blocking the action. Two caveats, since the credit system is currently AI-token-specific: - The balance is a single **token-denominated** pool. If you want a separate balance per feature (e.g. "project credits"), add a per-feature balance rather than reusing the token pool. - Decide the unit. Reusing the token pool only makes sense if your feature is denominated the same way. # Swapping the LLM Provider FastSvelte ships OpenAI as the default LLM, but no route, service, or billing code ever imports a provider SDK directly. Everything depends on a small `LLMClient` seam (see [AI Integration](https://docs.fastsvelte.dev/features/ai/#the-llmclient-interface)). To switch providers you write one class implementing that interface and repoint the dependency-injection container at it. Nothing else in the backend changes. ## The contract `backend/app/service/llm_client.py`: ```python class LLMClient(Protocol): async def structured(self, messages: list[dict], model: Type[T]) -> tuple[T, TokenUsage]: ... def stream(self, messages: list[dict]) -> AsyncIterator[str | TokenUsage]: ... ``` - **`structured()`** returns a parsed Pydantic model plus the call's `TokenUsage`. - **`stream()`** yields plain-text chunks and, as its **final item**, the call's `TokenUsage` (the caller accumulates text for the UI and forwards that final usage to billing). `messages` is an OpenAI-style `list[dict]` (roles `system` / `user` / `assistant`); each adapter is responsible for translating it into its own provider's request shape. ## Claude (Anthropic): a complete recipe Add `anthropic` to the backend dependencies, then create `backend/app/service/claude_client.py`: ```python from typing import AsyncIterator, Type, TypeVar from anthropic import AsyncAnthropic, NOT_GIVEN from pydantic import BaseModel from app.model.llm_usage_model import TokenUsage from app.service.llm_client import LLMClient T = TypeVar("T", bound=BaseModel) PROVIDER = "anthropic" def _split_system(messages: list[dict]) -> tuple[str | object, list[dict]]: # Anthropic takes the system prompt as a top-level arg, not a role in `messages`. system = next((m["content"] for m in messages if m["role"] == "system"), NOT_GIVEN) chat = [m for m in messages if m["role"] != "system"] return system, chat class ClaudeClient(LLMClient): def __init__(self, model: str, max_tokens: int = 4096, api_key: str | None = None): self.client = AsyncAnthropic(api_key=api_key) self.model = model self.max_tokens = max_tokens async def structured(self, messages: list[dict], model: Type[T]) -> tuple[T, TokenUsage]: system, chat = _split_system(messages) response = await self.client.messages.parse( model=self.model, max_tokens=self.max_tokens, system=system, messages=chat, output_format=model, # Pydantic class -> response.parsed_output ) return response.parsed_output, self._usage(response.usage) async def stream(self, messages: list[dict]) -> AsyncIterator[str | TokenUsage]: system, chat = _split_system(messages) async with self.client.messages.stream( model=self.model, max_tokens=self.max_tokens, system=system, messages=chat, ) as stream: async for text in stream.text_stream: yield text final = await stream.get_final_message() yield self._usage(final.usage) def _usage(self, usage) -> TokenUsage: # Anthropic reports input/output separately; total is the sum. return TokenUsage( provider=PROVIDER, model=self.model, input_tokens=usage.input_tokens, output_tokens=usage.output_tokens, total_tokens=usage.input_tokens + usage.output_tokens, ) ``` The shape mirrors the shipped `OpenAIClient`: `structured()` uses the SDK's structured-output helper (`messages.parse` with a Pydantic `output_format`), and `stream()` yields text deltas then a final `TokenUsage`. The only provider-specific work is lifting the `system` role out of `messages` (Anthropic takes it as a top-level argument) and supplying `max_tokens`, which Anthropic requires. ## Wire it in Point the dependency-injection container at the new class. In `backend/app/config/container.py`, swap the `openai_client` provider for a `claude_client` and inject it wherever `openai_client` was wired (the `llm_client=...` argument): ```python claude_client = providers.Singleton( ClaudeClient, model=settings.anthropic_model, # e.g. "claude-opus-4-8" api_key=settings.anthropic_api_key, ) ``` Add `anthropic_api_key` / `anthropic_model` to `Settings` (mirroring the existing OpenAI fields), which read `FS_ANTHROPIC_API_KEY` / `FS_ANTHROPIC_MODEL` from the environment. ## Add the pricing row Cost is computed from the `model_price` table, so **add a row for the Claude model under provider `anthropic`** or cost calculation fails. See [AI Usage & Credit Billing](https://docs.fastsvelte.dev/features/ai-billing/#model-pricing). Current Claude pricing (per 1M tokens, input / output): | Model | Input / 1M | Output / 1M | | ------------------- | ---------- | ----------- | | `claude-opus-4-8` | $5.00 | $25.00 | | `claude-sonnet-4-6` | $3.00 | $15.00 | | `claude-haiku-4-5` | $1.00 | $5.00 | ## Other providers **Gemini, LiteLLM, or any other provider** follow the exact same shape: one class implementing `structured()` + `stream()`, mapping the provider's usage fields into `TokenUsage`, wired via `container.py`, with a matching `model_price` row. The copilot, billing, routes, and UI are untouched. ## Next steps - **[AI Integration](https://docs.fastsvelte.dev/features/ai/index.md)**: the `LLMClient` seam, the sample copilot, and streaming. - **[AI Usage & Credit Billing](https://docs.fastsvelte.dev/features/ai-billing/index.md)**: how every call is metered and billed. # Changing the Password Policy FastSvelte enforces a length-only password policy out of the box, checked in both the API and the forms. After this guide you can change the limits or add rules of your own without locking existing users out. ## What's enforced now Passwords must be **8 to 64 characters**, checked on signup, invitation accept and password reset. Both the API and the forms enforce it, so a request that skips the browser is rejected too. Login and the current-password field on the profile page are the exception: they have a maximum but no minimum. They check an existing password rather than set a new one, so one that's too short should come back as a failed login, not as a form error announcing how long passwords have to be. ## Changing the length Both limits are constants in `backend/app/model/common.py`: ```python PASSWORD_MIN_LENGTH = 8 PASSWORD_MAX_LENGTH = 64 ``` Raising the maximum is safe. Lowering it is the change worth thinking about: password managers generate 20 characters or more by default, and a passphrase like `sturdy walnut harbor lamp` is 25, so a low cap turns away the strongest passwords your users have. The frontend forms carry the same numbers and need updating too. ## Adding a rule of your own Attach a validator to `NewPassword` in `backend/app/model/common.py`. This one requires a mix of character types, the rule most often asked for: ```python from pydantic import AfterValidator, Field def _require_character_classes(value: str) -> str: missing = [] if not any(c.islower() for c in value): missing.append("a lowercase letter") if not any(c.isupper() for c in value): missing.append("an uppercase letter") if not any(c.isdigit() for c in value): missing.append("a digit") if all(c.isalnum() for c in value): missing.append("a symbol") if missing: raise ValueError("must contain " + ", ".join(missing)) return value NewPassword = Annotated[ str, Field(min_length=PASSWORD_MIN_LENGTH, max_length=PASSWORD_MAX_LENGTH), AfterValidator(_require_character_classes), ] ``` Collecting the missing pieces before raising means the user is told everything at once ("must contain an uppercase letter, a digit, a symbol") rather than fixing one and discovering the next. Why not `Field(pattern=...)`? The usual regex trick, `(?=.*[A-Z])`, uses lookahead, and Pydantic's default regex engine doesn't support it. The model then fails to build at import time rather than at validation time, so the whole app stops starting. Use a validator instead. Add rules to `NewPassword` only, never to `SubmittedPassword` `SubmittedPassword` is for passwords being *checked*, at login and in the current-password field. A rule there rejects anyone whose existing password predates it, and turns a normal failed login into a validation error. ## Mirroring it in the frontend The forms validate before submitting, so a rule added only on the backend leaves the user stuck. Worse, they can't tell why: validation errors arrive as `{"message": "Invalid request data", "details": {...}}`, and the forms display `message`. So the browser says "Invalid request data" while the actual reason sits in `details`. Update the zod schema in each form that sets a password: | Form | File | | ----------------- | -------------------------------------------------------- | | Signup | `frontend/src/routes/(auth)/signup/+page.svelte` | | Invitation accept | `frontend/src/routes/(auth)/invite/accept/+page.svelte` | | Reset password | `frontend/src/routes/(auth)/reset-password/+page.svelte` | | Change password | `frontend/src/routes/(protected)/profile/+page.svelte` | ```ts password: z .string() .min(8, 'Password must be at least 8 characters') .max(64, 'Password must be at most 64 characters') .regex(/[a-z]/, 'Password must contain a lowercase letter') .regex(/[A-Z]/, 'Password must contain an uppercase letter') .regex(/[0-9]/, 'Password must contain a digit') .regex(/[^A-Za-z0-9]/, 'Password must contain a symbol') ``` Zod records every rule that failed, and the form shows them one at a time as the user types. Leave `frontend/src/routes/(auth)/login/+page.svelte` alone, for the same reason as `SubmittedPassword`. Why there are no character rules by default Requiring an uppercase letter and a symbol sounds stricter, but it mostly produces `Password1!`. Length is what actually costs an attacker time, and a rule that blocks `sturdy walnut harbor lamp` while allowing `Password1!` is working against you. Current guidance from NIST, the US standards body whose recommendations most of the industry follows, is to require length, allow everything else, and drop composition rules entirely. That's the default here. Turning them on is a decision about your own users and any compliance regime you answer to, which is why it's a few lines rather than a setting. # Replacing the Demo FastSvelte ships an AI copilot demo (note Improve / Summarize) to show the patterns. After this guide you can strip it and start clean on your own product. ## 1. Plan your application - What is your main domain model? (Projects, Tasks, Documents, …) - What actions can users perform? (create, edit, share, export, …) - What billing model will you use? (per-seat, usage-based, tiered, …) ## 2. Build your features Follow **[Adding a Feature](https://docs.fastsvelte.dev/guides/adding-a-feature/index.md)** to add an entity end to end (schema → repository → service → route → UI), then apply the same pattern to your own domain. ## 3. Remove the demo code Keep the AI billing infrastructure The AI billing/usage infrastructure (`llm_client.py`, `openai_client.py`, and the usage/credit services and repos) is reusable. Delete it only if you're sure you won't add AI features of your own (see [AI Copilot](https://docs.fastsvelte.dev/features/ai/index.md)). **Backend:** ```bash cd backend rm app/model/note_model.py app/model/copilot_model.py rm app/service/note_service.py app/service/copilot_service.py rm app/api/route/note_route.py rm app/data/repo/note_repo.py ``` **Frontend:** ```bash cd frontend rm -rf src/routes/(protected)/notes ``` **Database.** Drop the note table: ```bash cd backend/db ./sqitch.sh add remove_note_table -n "Remove note demo table" ``` Edit `deploy/remove_note_table.sql`: ```sql -- Deploy fastsvelte:remove_note_table to pg BEGIN; DROP TABLE IF EXISTS fastsvelte.note CASCADE; COMMIT; ``` Fill `revert/remove_note_table.sql` with the original `CREATE TABLE` from `deploy/001_schema.sql`, then `./sqitch.sh dev deploy`. **Optional.** If you're not using AI at all: ```bash cd backend uv remove openai ``` # Migrating an Existing Project You have an app that works. Maybe you built it with AI, maybe by hand, and now you want it standing on FastSvelte's foundation instead of hardening your own. This guide is the process we used to migrate a production app onto FastSvelte ourselves, driven by an AI agent the whole way. The direction matters: you do not port FastSvelte into your project. You move your project onto a fresh FastSvelte clone, where auth, billing, organizations, and email already work, so the only things your agent rebuilds are your own features. The process has three phases, and each one ends with the agent stopping so you can review. That stop-and-review rhythm is the whole trick. Agents are good at executing a small, well-defined step and bad at knowing when they have drifted; the phase boundaries are where you catch the drift. ## Prerequisites - A FastSvelte clone, set up and running locally (see [Project Setup](https://docs.fastsvelte.dev/guides/project-setup/index.md)) - Your existing project available on the same machine - An AI agent (Claude Code, Cursor, or similar) running **in the FastSvelte clone**, not in your old project - 15 minutes to read the plan your agent writes. Do not skip that part. The old project is read-only The agent reads your existing code to understand it and never modifies it. If anything goes wrong, your old app is untouched and still deployable. Commit after `init.py` Run the initial setup, then commit before the migration starts. Phase 3 uses "revert to the last good commit" as its safety net, and that only protects you if your configured, working baseline is actually committed rather than sitting in the working tree. ## Phase 1: Plan the migration Paste this prompt into your agent, with the path filled in: ```text I want to migrate an existing project onto this FastSvelte codebase. The existing project lives at: /full/path/to/my-old-project Treat it as read-only: read anything, modify nothing there. Create a folder named migration/ at the root of this project and write migration/plan.md containing: 1. Feature inventory: every user-facing feature of the existing project, with the routes, data models, and third-party integrations behind it. 2. Already covered: which of those features FastSvelte already provides (auth, organizations, billing, email, AI metering), and what configuration they need instead of code. 3. To port: the features that must be rebuilt here, each mapped to the FastSvelte layers it will touch (schema, repository, service, route, frontend). 4. Data migration: what existing data must move, and a first idea of how. 5. Open questions: anything you could not determine from the code alone. Never assume. When a feature's purpose, a business rule, or an intended behavior is not clear from the code, stop and ask me instead of guessing. A wrong assumption in this plan changes every task that follows. Do not write or change any other file yet. When the plan is ready, stop so I can review it. ``` Expect the agent to ask questions while it plans. That is the behavior you want: an agent that asks ten questions writes a dramatically better plan than one that quietly guesses ten answers, and the plan is where guesses are cheapest to prevent. Then read `migration/plan.md` carefully. This is the highest-leverage review of the whole migration: a wrong assumption caught here costs one sentence to fix; caught in phase 3 it costs an afternoon. Check three things in particular: - **Is the feature inventory complete?** You know your product; the agent only knows the code. Add what it missed. - **Did it map features to FastSvelte's built-ins correctly?** A common miss is rebuilding something FastSvelte already ships, like password reset or invitations. - **Are the open questions answered?** Answer them in a follow-up message and have the agent update the plan. Iterate until the plan describes the migration you actually want. Then move on. ## Phase 2: Break the plan into tasks Once the plan is right, paste this: ```text The migration plan in migration/plan.md is approved. Now break it into tasks. Create migration/tasks/, migration/tasks/done/, and migration/tasks/index.md. Split the plan into tasks that each take roughly 30 to 45 minutes. One task is one .md file in migration/tasks/, structured like this: --- title: effort: 30-45m depends-on: --- # ## Scope - [ ] concrete steps, checkable one by one ## Pointers - Old project: <files to read> - This project: <files to create or change> ## Done when - <a verifiable outcome, including the tests to run> ## Manual check - <only when needed: what I should click through in the UI before we continue> Rules: - Follow the porting order from the plan; respect dependencies. - Every task that changes the schema, a visible page, or anything in auth or billing must include a Manual check section. - migration/tasks/index.md lists every task in execution order with a one-line description and a status. Do not implement anything yet. Stop when the tasks are ready for my review. ``` Skim the tasks. The thing to verify is size: if a task says "port the entire dashboard," send it back to be split. Small tasks are what keep every diff reviewable and every failure cheap. Why 30 to 45 minutes That is the window where an agent stays reliable and a human still actually reads the diff. Both fall off fast beyond it. ## Phase 3: Implement, one task at a time Now the loop. Each iteration is one prompt: ```text Work on the next task in migration/tasks/index.md. Implement exactly that one task and nothing beyond it. Run the tests it names. When it is done, move its file to migration/tasks/done/, update the status in index.md, and stop. If the task has a Manual check section, tell me what to verify before we continue. ``` Between iterations, you do three small things: 1. **Review the diff.** One task's worth of changes, a few minutes. 1. **Commit.** One task, one commit. If a task turns out wrong later, it reverts cleanly. 1. **Run the manual check when the task asks for one.** Click through the flow in the running app. The agent cannot see your UI; this is the part only you can do. Repeat until `migration/tasks/` is empty and `done/` is full. Do not let the agent run ahead "Implement exactly that one task and stop" is in the prompt for a reason. An agent that batches five tasks produces one unreviewable diff, and you lose the ability to catch drift at the boundaries. If it runs ahead anyway, revert to the last good commit and re-run the single task. ## Phase 4: Cutover When every task is done: 1. **Move the data.** Write the import against your new schema (a script or a one-off Sqitch migration), run it against a staging database first, and verify counts and a few known records by hand. 1. **Deploy** the FastSvelte app alongside the old one (see [Deployment](https://docs.fastsvelte.dev/deployment/index.md)) and use it with real accounts for a few days. 1. **Switch DNS** when you trust it. Keep the old app runnable until you have been on the new one comfortably for a while. The `migration/` folder has served its purpose at this point. Keep it in git history and delete it from the working tree, the same way you would any finished planning document. # Type-Safe API Client (Orval) FastSvelte keeps the frontend and backend in sync with a TypeScript API client **generated from the backend's OpenAPI spec** by Orval. Change a Pydantic model or route, regenerate, and the TypeScript compiler catches any breakage at compile time. ## Regenerate after backend changes ```bash cd frontend npm run generate # reads the OpenAPI spec and regenerates the client (backend must be running) ``` Generated code lives in `frontend/src/lib/api/gen/`. Don't edit generated code by hand Everything under `gen/` is overwritten on every generate. Hand edits are silently lost. ## How it works 1. Define Pydantic models and routes with `response_model` and `operation_id`: ```python @router.post("", response_model=NoteResponse, operation_id="createNote") async def create_note(data: CreateNoteRequest, ...): ... ``` - `response_model` → the response type in the OpenAPI spec - `operation_id` → the generated TypeScript function name 1. FastAPI generates the OpenAPI spec from the decorators and models. 1. Orval reads the spec and emits typed functions and models. 1. Import and use them with full type safety: ```typescript import { createNote } from "$lib/api/gen/notes"; import type { CreateNoteRequest, NoteResponse } from "$lib/api/gen/model"; const note: NoteResponse = await createNote({ title: "My Note", content: "..." }); ``` When you change the backend and regenerate, the TypeScript compiler flags any frontend code that no longer matches. # Upgrading Dependencies After this guide you can keep dependencies current and secure using Dependabot plus a verify workflow. ## Upgrade policy FastSvelte ships a Dependabot config (`.github/dependabot.yml`) so routine upgrades are automated and reviewable: - **Automated, weekly:** Dependabot opens **grouped** PRs once a week per ecosystem: `npm` (frontend), `npm` (landing), and `uv` (backend). The backend uses a 7-day cooldown so brand-new releases settle before they're proposed. - **Minor & patch upgrades:** handled by those weekly PRs. Review and merge once CI passes (`backend.yml`, `frontend.yml`, `landing.yml`). - **Major versions:** **excluded** from Dependabot (`version-update:semver-major` is ignored) and done deliberately, one component at a time, since they may require code changes. Follow the major-version workflow below. - **Security updates:** fast-track immediately, outside the weekly cadence. - **Version pinning:** `pyproject.toml` and `package.json` declare lower-bound (`>=`) ranges; the lockfiles (`uv.lock`, `package-lock.json`) pin exact versions and are committed, so installs are reproducible while ranges stay flexible. ## Backend dependencies (Python) FastSvelte uses `uv` to manage Python dependencies. ```bash cd backend # Upgrade all dependencies (or a specific one) uv sync --upgrade uv add --upgrade package_name # Verify compatibility uv run pytest ``` What the smoke tests cover Smoke tests in `backend/test/smoke/` verify app startup, database connectivity, and API functionality. Database tests require PostgreSQL running (`docker compose up db -d`) but skip if it's unavailable. ## Frontend dependencies (npm) The frontend and landing page use `package.json` + `package-lock.json`. ```bash # Frontend cd frontend npm outdated # check what's outdated npm update # update within semver ranges npm run build && npm run check && npm run lint && npm run test # Landing (simpler, no tests by default) cd ../landing npm outdated && npm update npm run build && npm run check && npm run lint ``` ### Major version upgrades ```bash npm install -g npm-check-updates # one-time cd frontend # or cd landing ncu # preview ncu -u # bump package.json to latest npm install npm run build && npm run check && npm run lint npm run test # frontend only (landing has no tests by default) ``` **Important:** - Commit `package-lock.json` after upgrades. - Test thoroughly after major updates, and upgrade one component at a time (backend, frontend, landing) for easier troubleshooting. - Frontend smoke tests live in `frontend/src/tests/smoke/` and run with `npm run test`. - To add tests to landing, run `npx sv add vitest`. # Getting Updates [FastSvelte](https://fastsvelte.dev) is yours to customize and extend. From the first week you will add your own migrations, routes, and pages, and your project starts to diverge from the kit. That is by design: FastSvelte is a starting point you own, not a framework you track. Be realistic about what that means for updates: - **Early on**, merging upstream works well. Your diff is small and conflicts are few. - **Over time, merging stops being sustainable.** Your schema, routes, and components drift away from the kit's, and a full merge brings more conflict than value. This is normal and expected, not a failure. - **What always works: taking specific fixes.** FastSvelte ships security and critical fixes as standalone commits precisely so you can cherry-pick them into any project, no matter how far it has diverged. Starting a new project? Clone the latest FastSvelte. Everything below is for projects already in flight. ## Set Up the Upstream Remote Every method below needs FastSvelte available as a remote: ```bash # During initial setup (instead of removing origin) git clone https://github.com/harunzafer/fastsvelte.git my-project cd my-project git remote rename origin upstream git remote add origin <your-repo-url> git push -u origin main # If you already removed origin, add upstream back git remote add upstream https://github.com/harunzafer/fastsvelte.git ``` ## Security and Critical Fixes Read this section even if you never merge. We publish security and critical fixes as **standalone commits**, prefixed `[security]` or `[fix]`, so they can be taken in isolation: ```bash git fetch upstream # List the fixes you don't have yet git log --oneline --grep='^\[security\]' --grep='^\[fix\]' HEAD..upstream/main # Take one git cherry-pick <sha> ``` If a fix requires anything beyond the code change (rotating a secret, invalidating sessions), the commit message says so. Read it before picking. If the cherry-pick conflicts with your customizations, the fix is usually small: inspect it with `git show <sha>` and apply the change manually. Get notified of security fixes On GitHub, Watch the FastSvelte repository with Custom → Releases. Every `[security]` fix is also published as a GitHub Release, and critical ones are announced to customers by email. ## After Any Update Whatever method you used, finish with these steps: ```bash # If the update touches backend/db/, deploy the new migrations cd backend/db && ./sqitch.sh dev deploy # repeat against prod when you release # If backend API routes or models changed, regenerate the API client cd frontend && npm install && npm run generate # Test before pushing cd backend && pytest cd frontend && npm run build && npm run test cd landing && npm run build && npm run check ``` ## Update Methods Over a Project's Life ### Early project: merge While your project is young and close to the kit, merging takes everything at once: ```bash git fetch upstream git log --oneline HEAD..upstream/main # review what's coming git merge upstream/main # Resolve conflicts (usually container.py, routes.py), commit, test, push ``` ### Established project: cherry-pick what you need Once merging hurts more than it helps, switch to taking only what matters, using the same commands as the security section above. Cherry-picking works for any upstream commit, not only fixes. One caution: release commits build on each other, so picking a large feature release into a heavily diverged project can conflict extensively. Fixes are kept small for exactly this reason; features are take-at-your-own-risk. ### Fallback: manual copy For maximum control, skip git entirely: review the change on GitHub and copy what you need into your project. This always works, and for a heavily customized file it is often faster than resolving a conflict. ## Minimizing Conflicts **Extend rather than modify** to reduce merge conflicts. When you create new files instead of editing existing FastSvelte code, conflicts are limited to a few predictable integration points: - New routes, services, repositories in their own files - New frontend pages and components - New database migrations Conflicts still happen in the registration points (`container.py`, `routes.py`) where new components hook in, but they stay small and predictable. ## Best Practices 1. **Test updates in development first.** Never update production directly. 1. **Create a backup branch** before major updates. 1. **Review the commit message** of anything you pick: fixes that need manual steps say so there. ## Next steps - **[Development Workflow](https://docs.fastsvelte.dev/guides/development-workflow/index.md)**: build your application - **[Deployment](https://docs.fastsvelte.dev/deployment/index.md)**: deploy to production # Features # Authentication FastSvelte ships session-based authentication out of the box: email/password and Google OAuth login, email verification, password reset, and role-based access control. Sessions are server-side and carried in an HTTP-only cookie. ## Roles Roles are precedence-ordered (`backend/app/model/role_model.py`). Endpoints are guarded by `min_role_required(Role.X)`: a user satisfies the requirement if their role's precedence is **at least** the required role's. | Role | DB name | Precedence | | -------------- | ----------- | ---------- | | `READONLY` | `readonly` | 0 | | `MEMBER` | `member` | 1 | | `ORG_ADMIN` | `org_admin` | 2 | | `SYSTEM_ADMIN` | `sys_admin` | 3 | ## Sessions On login the backend creates a server-side session and sets a cookie (`backend/app/util/cookie_util.py`): - **Name**: `session_id` (configurable via `FS_SESSION_COOKIE_NAME`). - **`HttpOnly`**: always, so JavaScript can't read the token. - **`Secure`**: on in `beta`/`prod`, off in `dev`. - **`SameSite`**: `strict` in `beta`/`prod`, `lax` in `dev`. - **Max age**: `FS_SESSION_COOKIE_MAX_AGE` (default 24h). `POST /auth/logout` invalidates the session server-side and clears the cookie. Expired sessions are pruned by the cron job (`/cron`, gated by `FS_CRON_SECRET`; retention `FS_CRON_SESSION_RETENTION_DAYS`, default 7 days). ## Email & password | Endpoint | Purpose | | ----------------------- | ------------------------------------------------- | | `POST /auth/signup` | Create a user (B2C; see [modes](#b2c-vs-b2b)) | | `POST /auth/signup-org` | Create an organization + its admin (B2C) | | `POST /auth/login` | Log in; sets the session cookie | | `POST /password/forgot` | Email a password-reset link | | `POST /password/reset` | Reset the password with a token | | `POST /password/update` | Change the password while logged in (`READONLY`+) | Login requires a **verified email**; unverified accounts get `EmailNotVerified`. Password-reset emails are sent through the configured [email provider](https://docs.fastsvelte.dev/features/email/index.md). ## Google OAuth | Endpoint | Purpose | | -------------------------------------- | --------------------------------------------------------------- | | `GET /auth/oauth/google/authorize-url` | Returns the Google authorization URL | | `GET /auth/oauth/google/callback` | Handles the redirect, creates the session, redirects to the app | The flow uses a signed `state` parameter for CSRF protection (`backend/app/util/oauth_util.py`), validated on callback. Errors (cancelled, invalid state, etc.) redirect back to `/login?error=...`. For Google Cloud credentials and redirect-URI setup, see the [Integrations guide](https://docs.fastsvelte.dev/features/google-oauth/index.md); to add other providers, extend `oauth_util.py` and add routes in `auth_route.py`. ## B2C vs B2B Signup behavior depends on the app mode: - **B2C**: public `signup` and `signup-org` are open. - **B2B**: both return `404`; users join only via [organization invitations](https://docs.fastsvelte.dev/features/multi-tenancy/index.md). Only a `SYSTEM_ADMIN` creates organizations. See **[B2B Mode](https://docs.fastsvelte.dev/features/multi-tenancy/index.md)** for the full multi-tenant model. ## Configuration ```bash # Google OAuth FS_GOOGLE_CLIENT_ID="your-google-client-id.apps.googleusercontent.com" FS_GOOGLE_CLIENT_SECRET="GOCSPX-your-google-client-secret" # Signs the OAuth state parameter (CSRF protection) FS_JWT_SECRET_KEY="your-jwt-secret-key" # Sessions FS_SESSION_COOKIE_NAME="session_id" FS_SESSION_COOKIE_MAX_AGE=86400 # Session-cleanup cron FS_CRON_SECRET="your-secure-cron-secret" FS_CRON_SESSION_RETENTION_DAYS=7 ``` ## Next steps - **[Integrations](https://docs.fastsvelte.dev/features/google-oauth/index.md)**: Google OAuth provider setup and email configuration. - **[B2B Mode](https://docs.fastsvelte.dev/features/multi-tenancy/index.md)**: organizations, invitations, and roles. # Google OAuth FastSvelte supports social login via Google OAuth out of the box, alongside [email/password authentication](https://docs.fastsvelte.dev/features/authentication/index.md). The architecture extends to other providers. ## Setup 1. Go to [console.cloud.google.com](https://console.cloud.google.com) and create (or select) a project. 1. Enable the Google OAuth2 API. 1. Create an **OAuth 2.0 Client ID** (Web application). 1. Add the authorized redirect URI: `http://localhost:8000/auth/oauth/google/callback` (and your production equivalent). JWT secret for OAuth state OAuth flows use a JWT secret to sign the `state` parameter (CSRF protection). `init.py` auto-generates it. To generate manually: ```bash python -c "import secrets; print(secrets.token_urlsafe(32))" ``` ## Configuration ```bash # backend/.env FS_GOOGLE_CLIENT_ID="your-google-client-id.apps.googleusercontent.com" FS_GOOGLE_CLIENT_SECRET="GOCSPX-your-google-client-secret" # Signs the OAuth state parameter (CSRF protection) FS_JWT_SECRET_KEY="your-jwt-secret-key-here" ``` ## The Login Flow 1. **Frontend** requests `GET /auth/oauth/google/authorize-url`. 1. **Backend** returns the Google authorization URL (with a signed `state`). 1. **User** authenticates with Google. 1. **Google** redirects to `GET /auth/oauth/google/callback` with an authorization code. 1. **Backend** validates `state`, exchanges the code, creates/loads the user, and sets the session cookie. 1. **Frontend** lands logged in. OAuth errors redirect to `/login?error=...`. ## Linking with an existing account Sign-in with Google is matched to a user by **email address**, so a Google login and an email/password login for the same person resolve to one account, not two: - **The email matches a verified account.** The Google login is linked to it, and that user can then sign in either way. - **The email matches an account that never verified its email.** Google has now proven the person owns the address, so the account is claimed for them: its old password is removed, any existing sessions are signed out, and the email is marked verified. This closes a gap where someone could park on an email they do not own before the real owner signs in. - **No account has that email.** A new account is created (in B2C). In B2B, sign-up is invitation-only, so an unknown email is turned away. ## Adding More Providers To add GitHub, Microsoft, etc.: 1. Extend the helpers in `backend/app/util/oauth_util.py`. 1. Add provider routes in `backend/app/api/route/auth_route.py`. 1. Add the provider credentials to `backend/app/config/settings.py`. ## Troubleshooting - **Redirect URI mismatch**: it must match the provider config exactly (scheme, host, port, path). - **Invalid client**: recheck the client ID/secret. - **HTTPS**: most providers require HTTPS redirect URIs in production. See **[Authentication](https://docs.fastsvelte.dev/features/authentication/index.md)** for sessions, roles, and the rest of the auth model. # Billing & Subscriptions FastSvelte handles **subscription billing** through Stripe's **Customer Portal**, so there is no custom billing UI to build. Subscription state is **webhook-driven**: Stripe is the source of truth, and the backend updates the database only from webhook events sent to `/webhooks/stripe`. Credit-pack purchases have a second path: the billing page verifies the checkout with Stripe after the redirect, so a lost webhook cannot cost a customer their credits. Three columns hold the link: `organization.stripe_customer_id`, `plan.stripe_product_id`, and `organization_plan` (status, period, subscription id). ## Setup ### 1. API key Create a Stripe account and a [sandbox](https://docs.stripe.com/sandboxes/dashboard/manage#create-a-sandbox) for development, then copy your **Secret key** (Developers → API Keys; `sk_test_...` in sandbox) into `backend/.env`: ```bash FS_STRIPE_API_KEY=sk_test_YOUR_KEY_HERE ``` Only the secret key is needed; the portal handles all payment UI. Never commit it. ### 2. Create products In **Products → Add Product**, create your tiers and copy each **Product ID** (`prod_...`): - **Free tier**: a single **monthly** price; must be **$0** (auto-provisioned on first login). - **Paid tiers**: **monthly** and **annual** prices in the same currency. No public free tier? You still need a $0 default product for auto-provisioning. Name it "Default" and leave it out of the portal, so users can't select it. ### 3. Link products to plans The kit seeds three plans (Free, Professional, Premium). At **`/admin/plans`**, edit each and paste its **Stripe Product ID**. The default plan (Free) must map to your $0 product. It's validated and auto-assigned to new users. ### 4. Configure the Customer Portal In **Settings → Billing → Customer portal**: enable "customers can switch plans" and add the products/prices you want to offer; enable updating payment methods and viewing invoices, and optionally cancellation. Save. ## How plans are created and assigned The kit seeds three plans (Free, Professional, Premium); Free is flagged as the default. From there, assignment is automatic: 1. **New organizations get the Free plan on their own.** In B2C this happens at first login, in B2B at organization creation and on org admin logins. The same background step also creates the Stripe customer. It never blocks a login: if it can't finish (for example, Stripe keys are missing), it logs the error and tries again on the next login. 1. **Paid plans arrive by webhook.** When a customer subscribes through Stripe Checkout, the `customer.subscription.created/updated` events update the organization's plan to match Stripe. 1. **Fallback.** An organization with no plan row uses whichever plan is flagged as default. Only when both are missing does the app treat the organization as having no plan: the billing page says so, and AI calls run on purchased credits alone (or are refused when there are none). **If the billing page shows "No AI plan active":** no plan resolved for that organization. Check the backend log for onboarding errors (usually Stripe configuration) and confirm one plan still has the default flag set (Admin → Plans). ## Local development Forward Stripe webhooks to your backend with the [Stripe CLI](https://docs.stripe.com/stripe-cli/install): ```bash stripe login stripe listen --forward-to localhost:8000/webhooks/stripe ``` Copy the printed `whsec_...` into `backend/.env`, then restart the backend (keep `stripe listen` running in a second terminal): ```bash FS_STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxx ``` ## Testing Use Stripe [test cards](https://stripe.com/docs/testing) in sandbox (any future expiry, any CVC and postal code): | Card | Result | | --------------------- | --------- | | `4242 4242 4242 4242` | Success | | `4000 0000 0000 0002` | Declined | | `4000 0025 0000 3155` | 3D Secure | ## Going live In **Developers → Webhooks**, add an endpoint at `https://api.yourdomain.com/webhooks/stripe` subscribed to these events, and copy its signing secret to `FS_STRIPE_WEBHOOK_SECRET`: - `customer.subscription.created` / `updated` / `deleted` - `checkout.session.completed`: fulfills [AI credit-pack](https://docs.fastsvelte.dev/features/ai-billing/index.md) purchases Then switch to **Live mode**: recreate products with live pricing (free tier still $0), use live `sk_live_...` keys, update `/admin/plans` with the live Product IDs, configure the portal in live mode, and test a real upgrade. ## Troubleshooting **Free subscription didn't sync (dev).** If you logged in before `stripe listen` was running, the free subscription exists in Stripe but not your database ("No active plan found"). Fix it from `/billing` → **Manage Subscription** → in the portal click **Cancel subscription**, then **Don't cancel**. That fires `customer.subscription.updated` and syncs. (Or resend the original event from **Stripe → Events**.) ## Related [Plans & Usage](https://docs.fastsvelte.dev/features/plans-and-usage/index.md) · [AI Usage & Credit Billing](https://docs.fastsvelte.dev/features/ai-billing/index.md) · [Deployment](https://docs.fastsvelte.dev/deployment/index.md) # Plans & Usage FastSvelte meters usage and enforces per-feature limits through one generic system. The same engine powers the note quota and the AI token allotment, and you can extend it to anything. ## How it works - **Plans declare features once.** `PlanFeatures` in `backend/app/model/plan_model.py` is the single definition of what a plan's `features` JSON may contain. Each field is one feature and carries a default, a display label, and a kind: `"quota"` for a numeric per-period limit, `"flag"` for a boolean toggle. The kit ships `max_notes` and `token_limit` as quotas and `enable_ai` as a flag. Validation, enforcement and the billing page rows all derive from this model. - **Usage is metered per billing period.** `OrganizationUsageService` tracks each org's consumption per `FeatureKey` against the plan's limit, scoped to the current subscription period; it resets when the period rolls over. - **Limits are enforced.** Call `check_quota_for(...)` before an action and `update_usage(...)` after: ```python if not await usage_service.check_quota_for(org_id, FeatureKey.MAX_NOTES, 1): raise QuotaExceeded(FeatureKey.MAX_NOTES) # ... perform the action ... await usage_service.update_usage(org_id, FeatureKey.MAX_NOTES, 1) ``` The note demo does exactly this on create and delete. **Flags are enforced where the feature is served.** A quota is checked by the generic engine; a flag is checked by the endpoint that provides the feature. `enable_ai` gates the copilot endpoints with a `FEATURE_DISABLED` error, distinct from `QUOTA_EXCEEDED` so your frontend can tell "upgrade your plan" apart from "you ran out this period". ## Configuring limits Set each plan's limits in its `features` map via the admin Plans page or seed data. A numeric limit of `-1` means **unlimited**. `{"max_notes": -1}` grants notes with no ceiling, and the billing page shows "Unlimited" in place of a usage meter. Any other negative value is not unlimited. It resolves to a limit of zero and blocks the feature, so a plan you meant to uncap would refuse every request. See [Admin & User Dashboards](https://docs.fastsvelte.dev/features/admin-dashboards/index.md). How plans map to Stripe products is covered in [Billing & Subscriptions](https://docs.fastsvelte.dev/features/billing/index.md). ## When the JSON and the model disagree The features JSON is edited free-form, so it can drift from `PlanFeatures`. Reads never fail because of it: - A **missing** key falls back to the field default: quotas to 0 (the feature is blocked until you set a value), flags to false. - A value of the **wrong type** is ignored and the default applies, with a warning in the backend log. - An **extra** key is stored but ignored until you add a matching `PlanFeatures` field. The admin Plans form shows the same three cases as live warnings under the features editor, and the save response repeats them. They never block saving: you may edit a plan's JSON before updating `PlanFeatures` or the other way around, and either order works. ## Add your own metered feature Metering a new feature is one declaration and one enforcement call: add a `FeatureKey` and a `PlanFeatures` field, set limits per plan, and wrap your action with `check_quota_for` / `update_usage`. The admin form validation and the billing page row follow from the declaration, with no frontend change. Full walkthrough: **[Metering a Custom Feature](https://docs.fastsvelte.dev/guides/metering-a-custom-feature/index.md)**. ## AI usage The AI token allotment (`token_limit`) is just one metered feature, but it adds a paid **credit top-up** layer on top. See [AI Usage & Credit Billing](https://docs.fastsvelte.dev/features/ai-billing/index.md). # AI Integration FastSvelte ships a working, end-to-end AI feature so you can build your own on top of it instead of from scratch: a small provider-agnostic `LLMClient` seam, an OpenAI implementation, and a sample **note copilot** (Improve / Summarize) that streams its output to the UI. Every call is metered and billed. See **[AI Usage & Credit Billing](https://docs.fastsvelte.dev/features/ai-billing/index.md)**. ## Setup Configure the OpenAI key and model in `backend/.env`: ```bash FS_OPENAI_API_KEY="sk-proj-your-openai-api-key" FS_OPENAI_MODEL="gpt-5-mini" ``` `FS_OPENAI_MODEL` defaults to **`gpt-5-mini`**. The model name flows from settings into the client provider in `backend/app/config/container.py`: ```python openai_client = providers.Singleton( OpenAIClient, model=settings.openai_model, temperature=0.1, api_key=settings.openai_api_key, ) ``` Whatever model you set **must have a row in the `model_price` table**, or usage-cost calculation fails. See [AI Usage & Credit Billing](https://docs.fastsvelte.dev/features/ai-billing/#model-pricing). ## The `LLMClient` interface App code never imports the OpenAI SDK directly. It depends on a small `Protocol` in `backend/app/service/llm_client.py`: ```python class LLMClient(Protocol): async def structured( self, messages: list[dict], model: Type[T] ) -> tuple[T, TokenUsage]: ... def stream( self, messages: list[dict] ) -> AsyncIterator[str | TokenUsage]: ... ``` - **`structured()`** returns a parsed Pydantic model plus the call's `TokenUsage`. Usage travels with every response because every call is billable. - **`stream()`** yields plain-text chunks and, as its **final item**, the call's `TokenUsage` (captured from the terminal stream event). The caller accumulates text for the UI and forwards that final `TokenUsage` to billing. This seam is the whole point: swap providers by writing another `LLMClient`, and nothing else in the backend changes. ## OpenAI implementation `backend/app/service/openai_client.py` implements `LLMClient` against the OpenAI **Responses API** (`client.responses.parse` for structured output, `client.responses.stream` for streaming), not the older Chat Completions surface. Token usage is read from the response and returned as `TokenUsage(provider, model, input_tokens, output_tokens, total_tokens)`. ## The sample copilot `backend/app/service/copilot_service.py` exposes two actions, **Improve** and **Summarize**, each accepting an optional `tone`. They build a system+user message pair and return `llm_client.stream(...)`. They're surfaced on the note routes in `backend/app/api/route/note_route.py`: | Endpoint | Body | Role | Response | | ------------------------------------ | --------------------- | -------- | --------------------- | | `POST /notes/{id}/copilot/improve` | `{ "tone"?: string }` | `MEMBER` | streamed `text/plain` | | `POST /notes/{id}/copilot/summarize` | `{ "tone"?: string }` | `MEMBER` | streamed `text/plain` | Each handler loads the note (404 if missing), checks the org has AI capacity before streaming (estimated from the note length), then returns a `StreamingResponse`. The stream is wrapped by the billing service so the final usage is recorded automatically. See [AI Usage & Credit Billing](https://docs.fastsvelte.dev/features/ai-billing/index.md). ## Streaming to the frontend The frontend consumes the `text/plain` stream incrementally in `frontend/src/lib/api/copilotStream.ts`, appending chunks as they arrive so the user sees output render live. The copilot toolbar wires the Improve / Summarize buttons to these calls. ## Adding your own AI action 1. Add a method to `CopilotService` (or a new service) that builds messages and calls `llm_client.stream(...)` or `.structured(...)`. 1. Add a route that checks capacity, then wraps the stream with `AiUsageBillingService.stream_and_record(...)` (streaming) or calls `record_llm_usage(...)` after a `structured()` call. 1. Regenerate the typed API client and wire the UI. Billing is not optional plumbing you add later. Route every call through `AiUsageBillingService` so usage is metered consistently. ## Using a different provider OpenAI is the shipped implementation. To swap it, write one class implementing the `LLMClient` interface against your provider's SDK and point `container.py` at it. No route, service, billing, or UI code changes, since that seam is all the rest of the backend depends on. See **[Swapping the LLM Provider](https://docs.fastsvelte.dev/guides/swapping-llm-provider/index.md)** for a complete, drop-in Claude (Anthropic) recipe and the Gemini / LiteLLM pattern. ## Next steps - **[AI Usage & Credit Billing](https://docs.fastsvelte.dev/features/ai-billing/index.md)**: how calls are metered, the monthly allotment, credit packs, and usage reporting. - **[Stripe Integration](https://docs.fastsvelte.dev/features/billing/index.md)**: the subscription billing the AI allotment and credit purchases build on. # AI Usage & Credit Billing Every AI call is metered and billed against the **organization** (this kit is B2B multi-tenant; the org is the billing unit, which degrades naturally to "per user" for single-member orgs). Billing layers onto the existing [Stripe subscription](https://docs.fastsvelte.dev/features/billing/index.md) model. There is no parallel per-user credit system. A call's tokens are charged in a fixed order: 1. **Monthly allotment**: the plan's included token budget, resets each billing period. 1. **Credit-pack balance**: purchased, non-expiring tokens. 1. **Overage**: usage beyond both. **Blocked by default** (see [Overage settings](#overage-settings)). ## How a call is billed `backend/app/service/ai_usage_billing_service.py` owns this. Two entry points: - **`ensure_ai_in_plan(org_id)`**, then **`has_ai_capacity(org_id, estimated_tokens)`**: pre-flight checks the copilot routes run *before* streaming. The first refuses with `FEATURE_DISABLED` when the plan's `enable_ai` flag is off. The second estimates tokens from input length (~4 chars/token) and refuses with `QuotaExceeded` when neither the allotment nor credits can cover the call. The flag comes first on purpose: an org whose tier has no AI is told to upgrade, not to top up credits it can never spend. - **`stream_and_record(...)`** / **`record_llm_usage(...)`**: after the call, the actual `TokenUsage` is split across allotment → credits → overage, the consumed buckets are debited, the USD cost is computed (see [Model pricing](#model-pricing)), and a row is written to `llm_usage_log` tagged with the `bucket` it drew from. For streaming calls, `stream_and_record` forwards text chunks to the client and bills the final `TokenUsage` once the stream ends, with identical accounting to a non-streamed call. ## The monthly allotment The allotment is just one feature in the generic [Plans & Usage](https://docs.fastsvelte.dev/features/plans-and-usage/index.md) system: the `token_limit` `FeatureKey`, metered per billing period and resetting each period. Set each plan's token budget alongside its other limits; it isn't a separate AI-only mechanism. An org with no `token_limit` has a zero allotment and must rely on credit packs. ## Switching AI off per tier `enable_ai` is a plan flag, and it is not the same switch as `token_limit: 0`: - `token_limit: 0` removes the monthly allotment, but purchased credit packs still spend. AI keeps working for as long as the org buys credits. - `enable_ai: false` turns AI off for the tier entirely, credits or no credits. The copilot endpoints refuse with `FEATURE_DISABLED`, and the billing page hides the Buy AI Credits card so the tier is never offered packs it cannot spend. The shipped Free plan sets both, which demonstrates the flag. A product that wants free tiers to buy credits and use AI sets `enable_ai: true` with `token_limit: 0` in the plan JSON; no code change is involved. ## Credit packs Packs are configured in **settings** (`settings.credit_packs`), with a default in `backend/app/config/settings.py` and overridable via the `FS_CREDIT_PACKS` env var (JSON). The defaults: | Pack | Tokens | Price | | ---------- | ---------- | ----- | | `pack_5m` | 5,000,000 | $39 | | `pack_10m` | 10,000,000 | $69 | | `pack_25m` | 25,000,000 | $149 | Change tokens or pricing in settings. No code edit is needed, and there are **no Stripe products to create** (checkout builds the price inline via Stripe `price_data`). Purchased tokens are **non-expiring** and live in `organization_credit_balance`, separate from the per-period allotment. **Purchase flow** (`backend/app/api/route/credit_pack_route.py`, all `ORG_ADMIN`): | Endpoint | Purpose | | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `GET /billing/credit-packs` | List available packs | | `POST /billing/credit-packs/checkout` | `{ pack_id, return_base_url }` → `{ url }`; redirect the user to this Stripe Checkout URL | | `POST /billing/credit-packs/verify` | `{ session_id }` → `{ status }`; called by the billing page after checkout returns, to confirm the purchase with Stripe and fulfill it if the webhook has not | The org must already have a Stripe customer (complete subscription billing setup first). Fulfillment runs through `CreditPackService.fulfill_purchase` from **two** paths: the Stripe **`checkout.session.completed`** webhook (`backend/app/api/route/stripe_webhook_route.py`) and the billing page's verify call after the redirect. Whichever arrives first credits the balance and appends an `organization_credit_transaction` audit row; the other is turned away by a unique payment reference on that log. A purchase can therefore never credit twice, and a lost webhook never costs a customer their credits. ## Model pricing USD cost per call is computed from the `model_price` table (seeded by migrations `006_model_price.sql` + `008_more_model_prices.sql`), which ships current OpenAI GPT-4.1 and GPT-5 series pricing (standard tier, per 1M tokens). A sample: | Provider | Model | Input / 1M | Output / 1M | | -------- | ---------------------- | ---------- | ----------- | | openai | gpt-5-mini *(default)* | $0.25 | $2.00 | | openai | gpt-5 | $1.25 | $10.00 | | openai | gpt-5-nano | $0.05 | $0.40 | | openai | gpt-4.1-mini | $0.40 | $1.60 | | openai | gpt-4o-mini | $0.15 | $0.60 | …plus `gpt-5.1`, `gpt-5.2`, `gpt-4.1`, `gpt-4o`, and the `-pro` variants. `LlmPricingService.compute_cost_usd` looks up the price (cached) and multiplies by input/output tokens. Every model needs a price row If you point the copilot at a model that isn't in this table, calls fail. Add a row (or a migration) for any model you enable. ## Usage reporting `backend/app/api/route/usage_route.py` exposes: | Endpoint | Role | Returns | | ---------------------- | ---------------------------------------------- | ------------------------------------------------------------------------ | | `GET /usage/summary` | `MEMBER` | used / limit / credit tokens for the period, plus the caller's own usage | | `GET /usage/history` | `MEMBER` (own rows) · `ORG_ADMIN`+ (whole org) | paginated per-call log (`limit`, `offset`) | | `GET /usage/top-users` | `ORG_ADMIN` | top users by tokens this period | | `GET /usage/fleet` | `SYSTEM_ADMIN` | fleet-wide rollup over `days` (backs the admin AI-usage page) | The user-facing usage page reads `/summary` + `/history`; the system-admin AI-usage page reads `/fleet`. ## Overage settings Overage is gated by two settings, **both `false` by default**, so usage beyond allotment + credits is blocked (a hard cap) rather than silently charged: - **`overage_enabled`** (org setting): an org opts into overage. - **`ai_overage_enabled`** (system setting, `SYSTEM_ADMIN`): an operator-level kill switch; an org's `overage_enabled` only takes effect if this is also on. Both use the existing generic setting mechanism (org settings via `/organization/{id}/{key}`, system settings via `/system/{key}`). Actually **charging** for overage (Stripe metered billing / invoice items) is intentionally not implemented yet. Leave overage off until you wire it up. ## Schema Migration `007_ai_credit_billing.sql` adds: - `llm_usage_log`: append-only per-request log (tokens, cost, `bucket` = `allotment` | `credit` | `overage`). - `organization_credit_balance`: running non-expiring credit-pack balance. - `organization_credit_transaction`: audit log of credit purchases/debits. ## Next steps - **[AI Integration](https://docs.fastsvelte.dev/features/ai/index.md)**: the LLM client, the sample copilot, and streaming. - **[Stripe Integration](https://docs.fastsvelte.dev/features/billing/index.md)**: the subscription billing this builds on. # B2B Mode [FastSvelte](https://fastsvelte.dev) supports two operational modes: **B2C** (Business-to-Consumer) and **B2B** (Business-to-Business). This guide explains how B2B mode works. ## What is B2B Mode? In B2B mode, FastSvelte operates as a closed registration system where: - Public signup is disabled - Users can only join via invitation - Organizations are managed by system administrators - Each organization has its own admins who manage members ## Initial Setup When you run `uv run init.py` and select **B2B mode**, the script creates a system administrator account (`sys_admin`) and sets `FS_MODE=b2b` in your environment. ## The B2B Flow ### 1. System Administrator Login After running `init.py`, log in with the sys_admin credentials created during setup. ### 2. Create an Organization 1. Navigate to **System > Organizations** in the sidebar 1. Click **Create Organization** 1. Fill in the organization details: 1. **Organization Name**: The company/organization name 1. **Admin Email**: Email address for the organization administrator 1. **Admin Full Name**: Full name for billing purposes 1. Click **Create Organization** **What happens next:** - A new organization is created in the database - A Stripe customer is automatically created for billing - An invitation email is sent to the admin email address - The organization admin can accept the invitation to create their account ### 3. Organization Admin Accepts Invitation The organization admin receives an invitation email with a unique link. When they click it: 1. They're directed to the invitation acceptance page 1. They set their password and complete their profile 1. Their account is created with the `org_admin` role 1. They can now log in and access their organization Email Service in Development In development mode, the email service defaults to `stub` (no actual emails sent). To get invitation links, check your backend terminal for `[STUB EMAIL]` blocks containing the invitation URL. Copy and paste this URL in your browser to accept the invitation. ### 4. Organization Admin Manages Members Once logged in, the org_admin can: - **View Members**: See all users in their organization - **Invite Users**: Send invitations to new members - **Change Roles**: Update member roles (readonly, member, org_admin) - **Remove Members**: Remove users from the organization Access these features via **Organization > Users** in the sidebar. ### 5. Member Invitation Flow When an org_admin invites a new member: 1. Navigate to **Organization > Invitations** 1. Create a new invitation with email and role 1. The invited user receives an email with an invitation link 1. They accept the invitation and create their account 1. They automatically join the organization with the assigned role ## User Roles in B2B Mode | Role | Description | Permissions | | ----------- | -------------------------- | ---------------------------------------------------- | | `sys_admin` | System Administrator | Full system access, can manage all organizations | | `org_admin` | Organization Administrator | Can manage their organization's members and settings | | `member` | Regular Member | Can use application features | | `readonly` | Read-only User | View-only access | ## What's Disabled in B2B Mode When running in B2B mode: - Public signup page (`/signup`) returns 404 - OAuth sign-in creates accounts only for existing invited users - Individual billing is hidden (billing is managed at the organization level) - Registration happens only through invitations ## Configuration B2B mode is controlled by the backend `FS_MODE` environment variable: ```bash # Backend (.env) FS_MODE=b2b ``` The frontend doesn't need a mode variable. It reads the mode from the backend's `/config` endpoint at runtime. Update `FS_MODE` and restart the backend. ## Common Workflows ### Adding a New Company 1. sys_admin creates organization 1. Organization admin accepts invitation 1. org_admin invites team members 1. Members accept invitations and join ### Managing Organization Members 1. org_admin views members at `/organization/users` 1. Change member roles with the "Change Role" button 1. Remove members with the "Remove" button 1. Cannot remove yourself or the last org_admin ### Suspending an Organization 1. sys_admin navigates to organization details 1. Clicks "Suspend Organization" 1. All users in the organization are blocked from logging in 1. Data is preserved and can be reactivated later # Database FastSvelte uses **PostgreSQL** with a multi-tenant schema and **Sqitch** for migrations. Repositories use raw SQL (no ORM), so you always see exactly what runs. ## Multi-tenant schema All business data is scoped to an **organization** (the tenant boundary). Core entities: - `user`: individual accounts - `organization`: the tenant; all business data belongs to one - `role`: `readonly`, `member`, `org_admin`, `sys_admin` (see [Authentication](https://docs.fastsvelte.dev/features/authentication/index.md)) - `session`: server-side sessions - `plan` / `organization_plan`: subscription tiers and the org's current plan This supports both individual users and teams without changing the data model. See [Multi-Tenancy (B2B)](https://docs.fastsvelte.dev/features/multi-tenancy/index.md) for the organization/invitation model. ## Connection config Connection settings come from `backend/.env` (`FS_DB_URL`, `FS_DB_SCHEMA`) via `backend/app/config/settings.py`, and are injected through the DI container's `db_config` into every repository, so repositories never construct their own connections. ## Migrations with Sqitch Migrations are plain SQL files in `backend/db/`, versioned with Sqitch and reviewed alongside code in pull requests. Create a migration: ```bash cd backend/db ./sqitch.sh add add_feature -n "Add feature table" ``` This creates three files: - `deploy/add_feature.sql`: how to apply the change - `revert/add_feature.sql`: how to undo it - `verify/add_feature.sql`: how to verify it worked Deploy: ```bash ./sqitch.sh dev deploy ``` The `sqitch.sh` wrapper runs Sqitch via the official Docker image (no local Sqitch install needed) and adds FastSvelte-specific safety: per-environment database URLs (dev/beta/gamma/prod/test), a check that every migration is wrapped in `BEGIN;`/`COMMIT;`, revert protection (requires `--to`), and `.env` loading per environment. Why Sqitch and raw SQL? Sqitch is language-agnostic and works with plain SQL, with no ORM lock-in. You review and optimize the exact SQL that runs, and can still adopt SQLAlchemy or another tool later if your project needs it. See [Architecture](https://docs.fastsvelte.dev/reference/architecture/index.md) for the repository-layer reasoning. # Transactional Email FastSvelte sends transactional email (verification, password reset, and organization invitations) through a pluggable provider. Pick one of **Resend**, **SendGrid**, or **Azure Communication Services**, or use the **stub** provider in development. ## Choosing a Provider Set `FS_EMAIL_PROVIDER` in `backend/.env` to one of: `resend`, `sendgrid`, `azure`, or `stub`. For the providers you don't use, clean up all three places: 1. **`backend/pyproject.toml`**: delete the two package lines you don't need, then run `uv sync`: ```toml "azure-communication-email>=1.1.0", # FS_EMAIL_PROVIDER=azure "resend[async]>=2.30.1", # FS_EMAIL_PROVIDER=resend "sendgrid>=6.12.5", # FS_EMAIL_PROVIDER=sendgrid ``` ```bash cd backend && uv sync ``` 1. **`backend/.env`**: remove the env vars for the unused providers. 1. **`backend/app/config/settings.py`**: remove the corresponding settings fields (e.g. `resend_api_key`, `resend_sender_address`, `resend_sender_name`). ## Resend A modern email API with a generous free tier. Recommended for most new projects. 1. Sign up at [resend.com](https://resend.com) and create an API key at [resend.com/api-keys](https://resend.com/api-keys). 1. Add and verify your sending domain at [resend.com/domains](https://resend.com/domains). 1. Configure `backend/.env`: ```bash FS_EMAIL_PROVIDER="resend" FS_RESEND_API_KEY="re_your-resend-api-key-here" FS_RESEND_SENDER_ADDRESS="noreply@yourdomain.com" FS_RESEND_SENDER_NAME="Your App Name" ``` ## SendGrid ```bash FS_EMAIL_PROVIDER="sendgrid" FS_SENDGRID_API_KEY="SG.your-sendgrid-api-key-here" FS_SENDGRID_SENDER_ADDRESS="noreply@yourdomain.com" FS_SENDGRID_SENDER_NAME="Your App Name" ``` Verify your sender email/domain in the SendGrid dashboard first. ## Azure Communication Services For Azure-based deployments: ```bash FS_EMAIL_PROVIDER="azure" FS_AZURE_EMAIL_CONNECTION_STRING="endpoint=https://..." FS_AZURE_EMAIL_SENDER_ADDRESS="noreply@yourdomain.com" ``` ## Development (Stub) For local development without sending real email: ```bash FS_EMAIL_PROVIDER="stub" ``` Emails are logged to the backend console instead of sent. Look for `[STUB EMAIL]` blocks containing verification and invitation links. ## Testing ```bash curl -X POST http://localhost:8000/password/forgot \ -H "Content-Type: application/json" \ -d '{"email": "test@example.com"}' ``` ## Troubleshooting - **Not sending**: verify the API key, confirm the sender domain/address is verified with the provider, and check the backend logs. - **Production**: set up SPF/DKIM for your sending domain. # Landing Page Template FastSvelte includes a standalone, conversion-focused **landing site** in `landing/`, a separate SvelteKit app from the dashboard (`frontend/`), so your marketing pages deploy independently of the app. It's themed (light/dark) and SEO-optimized out of the box. The steps below take you from the template to your own landing page. ## 1. Find it The landing site lives in `landing/`. The home page (`landing/src/routes/+page.svelte`) is assembled from section components in `landing/src/lib/components/landing/`. Edit a section to change its content; reorder or remove sections in `+page.svelte`. ## 2. Set your branding - `landing/src/lib/config.ts`: `appName` and `siteUrl`. - `Logo.svelte`: swap in your logo (light/dark variants). - `landing/.env`: browser-exposed vars use the `PUBLIC_*` prefix (e.g. `PUBLIC_APP_NAME`, `PUBLIC_API_BASE_URL`). Run `npx svelte-kit sync` after changing them. ## 3. Arrange your sections `+page.svelte` composes the page from these sections, in order: ```text <Topbar /> <Hero /> <FeatureList /> <!-- <Showcase /> --> <Testimonial /> <CTA /> <FAQ /> <Pricing /> <Footer /> ``` Reorder, drop, or duplicate any line to change the page. `Showcase` ships commented out; uncomment it to enable. `Newsletter` is included as a component but not placed on the page; add `<Newsletter />` (and its import) wherever you want it. ## 4. Write the copy Each section is its own component in `landing/src/lib/components/landing/`. Edit the one you want: - **Topbar**: navigation links and the primary CTA. - **Hero**: headline, subheading, and the main call to action. - **Features**: the grid of product capabilities. - **Showcase**: a "copilot in action" demo panel (off by default). - **Testimonial**: social proof and customer quotes. - **Pricing**: pricing tiers (this was previously named `BundleOffer`). - **FAQ**: common questions and answers. - **CTA**: the closing call to action. - **Newsletter**: an email-capture signup, UI only and off by default. Wire the `<form>` to your email provider. - **Footer**: links, legal, and social. ## 5. SEO is already handled Every route renders the `Seo` component (`landing/src/lib/components/Seo.svelte`), so titles, descriptions, self-referencing canonicals, and Open Graph / Twitter cards are set for you. There are no per-page URLs to hardcode. See **[SEO](https://docs.fastsvelte.dev/features/seo/index.md)** to customize it. ## 6. Ship it See `landing/README.md` for commands (`npm run dev`, `npm run build`). The landing site deploys independently. See [Deployment](https://docs.fastsvelte.dev/deployment/index.md). ## Next steps - **[SEO](https://docs.fastsvelte.dev/features/seo/index.md)**: sitemaps, robots, and metadata strategy. - **[Deployment](https://docs.fastsvelte.dev/deployment/index.md)**: hosting the landing site. # SEO & Page Metadata [FastSvelte](https://fastsvelte.dev)'s landing ships with a reusable `Seo` component (`src/lib/components/Seo.svelte`) that emits a correct, per-page `<head>`: a single title and meta description, a **self-referencing canonical**, and Open Graph / Twitter card tags. The homepage is already wired up. You only need this guide when you **add new routes**. ## Using the component Add one `<Seo>` per route, passing that page's title and description: ```text <script lang="ts"> import Seo from '$lib/components/Seo.svelte'; let { data } = $props(); </script> <Seo title={data.title} description={data.description} /> ``` A common pattern is to supply the values from the route's `+page.ts` load, as the homepage does: ```ts // src/routes/pricing/+page.ts import type { PageLoad } from './$types'; export const load: PageLoad = async ({ parent }) => { const { appName } = await parent(); return { title: `${appName} - Pricing`, description: 'Simple, transparent pricing for every stage of your SaaS.' }; }; ``` ### Props | Prop | Required | Description | | --------------- | -------- | -------------------------------------------------------------------------------- | | `title` | yes | Page `<title>` and default social title. | | `description` | yes | `<meta name="description">` and default social description. | | `ogTitle` | no | Override the social title (defaults to `title`). | | `ogDescription` | no | Override the social description (defaults to `description`). | | `image` | no | Absolute URL to a social-card image. When omitted, no `og:image` tag is emitted. | | `canonical` | no | Override the auto-derived canonical (rarely needed). | ## Why one component per page (and not the layout) It's tempting to put a default `<meta name="description">` or canonical in `+layout.svelte` so every page inherits it. **Don't.** SvelteKit deduplicates `<title>`, but it does **not** dedupe `<meta>` or `<link>` tags; they accumulate. Two things go wrong: - **Duplicate descriptions.** Any page that sets its *own* description on top of the layout default ships *two* `<meta name="description">` tags. (A page that relies solely on the default ships one; the trap only springs once a page tries to override it, which most will.) - **Leaked canonical.** A hardcoded homepage canonical in the layout is inherited by every page that doesn't set its own. That's a *consolidation hint* telling search engines those pages are duplicates of the homepage, so their ranking signals get folded into it and they don't rank as themselves. (It's a hint, not a `noindex`, so search engines may ignore it, but you don't want to be fighting it.) The `Seo` component avoids this by emitting exactly one description and a canonical derived from the **current path**, so every route points at itself automatically with nothing to hardcode: ```text / → https://yourdomain.com/ /pricing → https://yourdomain.com/pricing ``` ## Configure your domain Canonical and `og:url` tags are built from `config.siteUrl`, which reads the `PUBLIC_SITE_URL` environment variable (see `src/lib/config.ts`). Set this to your own domain so the tags don't point at `fastsvelte.dev`: ```bash # .env PUBLIC_SITE_URL=https://yourdomain.com ``` `og:site_name` uses `config.appName` (`PUBLIC_APP_NAME`), so set that too if you've renamed your app. ## Social card images The component omits `og:image` / `twitter:image` unless you pass an `image` prop. To enable rich social previews, add a `1200×630` PNG/JPG to `static/` and pass its absolute URL: ```text <Seo title={data.title} description={data.description} image={`${config.siteUrl}/images/og-image.png`} /> ``` ## Verify After `npm run build` (and `npm run preview`), inspect the homepage source. You should see exactly **one** `<meta name="description">` and **one** self-referencing `<link rel="canonical">`: ```bash curl -s http://localhost:4173/ | grep -E 'name="description"|rel="canonical"' ``` # Admin & User Dashboards FastSvelte ships role-aware dashboards so you don't build admin tooling from scratch. What each user sees is gated by their [role](https://docs.fastsvelte.dev/features/authentication/#roles). ## System Admin Platform-wide tools (`sys_admin`), under `/admin`: - **Analytics**: usage and signup metrics - **Users**: manage all users across every organization - **Plans**: define subscription tiers and link Stripe products (see [Billing & Subscriptions](https://docs.fastsvelte.dev/features/billing/index.md)) - **Organizations**: create and manage tenants (the entry point for [B2B onboarding](https://docs.fastsvelte.dev/features/multi-tenancy/index.md)) - **AI Usage**: fleet-wide token and credit usage (see [AI Usage & Credit Billing](https://docs.fastsvelte.dev/features/ai-billing/index.md)) - **Health**: system health checks - **Settings**: system-level settings ## Organization Admin For `org_admin` (B2B), under `/organization`: - **Members**: view and manage organization users, change roles - **Invitations**: invite teammates by email (see [Multi-Tenancy](https://docs.fastsvelte.dev/features/multi-tenancy/index.md)) ## User For every authenticated user: - **Dashboard**: their workspace overview - **Billing**: subscription plus AI credits and usage (see [AI Usage & Credit Billing](https://docs.fastsvelte.dev/features/ai-billing/index.md)) - **Profile / Settings**: account details and preferences Every view is built on the same [role-based access](https://docs.fastsvelte.dev/features/authentication/#roles) and the type-safe [API client](https://docs.fastsvelte.dev/guides/orval/index.md). # Security FastSvelte ships with security built in, not left as an exercise. The codebase went through a full security audit in 2026 covering authentication, sessions, Google sign-in, billing webhooks and the API surface, and every finding was fixed or documented. In plain terms, here is what protects your app out of the box: - Passwords hashed with **Argon2id**, the current best practice - Session, reset, verification and invitation tokens stored **only as hashes**, so a database leak does not hand out working credentials - Sign-in with Google protected against **account takeover and forged logins** - Brute force and email abuse blocked by **per-IP rate limits** - Strict **security headers** on every API response - Dependencies **patched and monitored** for new vulnerabilities The rest of this page explains each of these, and the [production checklist](#production-checklist) covers what to set before you go live. ## Passwords Passwords are hashed with **Argon2id** (`backend/app/util/hash_util.py`), a modern memory-hard algorithm. Plaintext passwords are never stored. Passwords must be 8 to 64 characters, enforced on signup, invitation accept and reset. See [Changing the Password Policy](https://docs.fastsvelte.dev/guides/changing-the-password-policy/index.md) to change the length or add your own rules. Changing a password from the profile page requires the current one, so a stolen session cookie is not enough to take over the account. Accounts created through Google have no password, so they see an account panel instead of the form. A password change also signs the user out on other devices (`backend/app/service/password_service.py`): - **Changed from the profile page:** other sessions are revoked, the current one is kept. - **Reset with an emailed link:** all sessions are revoked, including any an attacker is holding. ## Sessions - Tokens are 256-bit random values (`secrets.token_urlsafe(32)`). - Only a **SHA-256 hash** of the token is stored server-side, so a database leak doesn't expose usable session tokens. Password-reset, email-verification and invitation tokens are stored the same way: the real token only ever travels in the emailed link. - The cookie is **HttpOnly** (JavaScript can't read it), **Secure** outside `dev`, and **SameSite** `strict` outside `dev` (`lax` in dev). - Logout invalidates the session server-side; expired sessions are pruned by the [cron job](https://docs.fastsvelte.dev/reference/configuration/#background-jobs-cron). See [Authentication](https://docs.fastsvelte.dev/features/authentication/index.md) for the full model. ## CSRF Session cookies use `SameSite` `strict` in production, so browsers refuse to attach them to requests started by other sites. This protection assumes the frontend and the API share a registrable domain (for example `app.example.com` and `api.example.com`). Deploying them on unrelated domains would require `SameSite=None`, which removes the protection entirely. If you must deploy cross-site, add CSRF tokens or an Origin check first. ## Access control Precedence-based roles (`readonly` < `member` < `org_admin` < `sys_admin`) gate every route via `min_role_required(...)`. All business data is organization-scoped, so tenants are isolated. See [Multi-Tenancy](https://docs.fastsvelte.dev/features/multi-tenancy/index.md). ## Google sign-in protections Sign-in with Google is hardened against the two ways it's commonly abused: - **Account takeover by email.** Google is only trusted to identify a user when it confirms the email address is verified. An unverified address is rejected, so nobody can link a Google login to someone else's existing account by claiming their email. - **Forged logins.** The flow is bound to the browser that started it (a signed `state` value plus a matching one-time cookie), so a sign-in link can't be crafted elsewhere and used to log someone into an attacker's account. See [Google OAuth](https://docs.fastsvelte.dev/features/google-oauth/index.md) for setup. ## AI spend protection AI usage is **hard-capped by default**: when an organization exhausts its allotment + credits, calls are blocked rather than silently billed. Overage requires turning on *both* an org setting and a system-level kill switch, neither enabled by default. See [AI Usage & Credit Billing](https://docs.fastsvelte.dev/features/ai-billing/#overage-settings). This protects you from runaway model spend. ## CORS & email verification Allowed origins are configured per environment in `backend/app/config/settings.py` and tighten in production. Accounts must verify their email before they can log in. ## Rate limiting The abuse-prone public endpoints are rate limited per client IP, so one source can't hammer them: | Endpoint | Limit | | -------------------------------------------- | ---------- | | `POST /auth/login` | 5 / 15 min | | `POST /auth/signup`, `POST /auth/signup-org` | 3 / hour | | `POST /password/forgot` | 3 / hour | | `POST /auth/resend-verification` | 10 / min | The priority is the endpoints that **send email to a user-supplied address** (signup, forgot-password, resend-verification): left open they let an attacker burn your email spend and sender reputation regardless of how much traffic you have. Login is capped against credential stuffing. A breach returns the standard `ErrorResponse` with a `Retry-After` header. To rate limit another route, add the dependency to it: ```python from fastapi import Depends from app.util.rate_limit import rate_limit @router.post("/expensive", dependencies=[Depends(rate_limit("10/minute"))]) ``` ### Storage (read before scaling) Counters live in **process memory by default** (`FS_RATE_LIMIT_STORAGE_URI=async+memory://`). The shipped container runs a single process, so this is correct for **one instance**. But each instance keeps its own counters, so if you run **more than one instance** (horizontal scaling / replicas behind a load balancer) the effective limit multiplies by the number of instances. For a real limit across instances, point it at a shared store: ```text FS_RATE_LIMIT_STORAGE_URI=async+redis://your-redis-host:6379/0 ``` Async Redis needs the `coredis` package (`uv add coredis`). ### Behind a proxy The limiter reads the client IP from `X-Forwarded-For`, which hosting platforms set for you. If you'd rather rate limit at the edge, a reverse proxy like nginx can do it too. See its [`limit_req` docs](https://nginx.org/en/docs/http/ngx_http_limit_req_module.html). ### Deliberately left out Kept out to stay lean; add if your threat model calls for it: - **Per-account login limiting** (keying login on the target email, not just the IP) defends a distributed attack against one account. Worth adding once you have accounts worth attacking. - **The AI endpoint** is authenticated, so it's not a public abuse surface, and it's already hard-capped by the AI spend protection above. It gets no separate HTTP limit. - **Reset and verify token endpoints** rely on high-entropy tokens rather than a request limit. ## Dependencies Every dependency is pinned to an exact version (`uv.lock`, `package-lock.json`), so builds are reproducible and nothing updates without you noticing. The stack ships with **zero known vulnerabilities** at release, and the repository has automated security alerts turned on, so newly disclosed issues surface as ready-to-merge fix requests. Keeping current is a routine, not a scramble. ## HTTP security headers FastSvelte protects API responses by default and provides a ready-to-use header policy for your frontend. ### API: already protected FastSvelte adds these headers to every API response, including errors: - **No caching:** private API data is not stored in browser or shared caches. - **No content guessing:** browsers cannot treat JSON as HTML or another unsafe type. - **No embedding:** API responses cannot be displayed inside another website. - **No referrer leakage:** URLs and IDs are not passed to third-party sites. No setup is required. These are set in `backend/app/api/middleware/security_headers.py`. ### Frontend: add at your host Your frontend host, CDN, or reverse proxy must send these headers for the app. They help ensure the app loads only trusted resources, stays HTTPS-only, cannot be embedded by another site, and does not enable unused browser features. The headers, and where to put them, are in the guide for your host: [Vercel](https://docs.fastsvelte.dev/deployment/fly-neon-vercel/#security-headers), [Railway](https://docs.fastsvelte.dev/deployment/railway/#security-headers), [Azure](https://docs.fastsvelte.dev/deployment/azure/#security-headers), [DigitalOcean](https://docs.fastsvelte.dev/deployment/digitalocean/#security-headers), [self-hosting](https://docs.fastsvelte.dev/deployment/self-hosting/#security-headers). Why the policy allows inline styles The policy sets `style-src 'self' 'unsafe-inline'`. Svelte animations inject an inline `<style>` element at runtime, so transitions break without it. This applies to styles only. Scripts stay limited to your own domain, which is where the real risk lies. ### Check after deploy ```bash curl -sI https://app.yourdomain.com | grep -i "content-security\|x-content-type\|referrer\|strict-transport" curl -sI https://api.yourdomain.com/ping | grep -i "content-security\|x-content-type" ``` If the app does not load correctly, check the browser console for CSP errors. Most often, the API URL is missing from `connect-src`. ## Production checklist - Strong, unique `FS_JWT_SECRET_KEY` and `FS_CRON_SECRET`. - Serve over HTTPS so `Secure` cookies take effect. - Strict `FS_CORS_ORIGINS` for production. - Stripe live keys + verified webhook secret (see [Billing & Subscriptions](https://docs.fastsvelte.dev/features/billing/index.md)). - Add the [frontend security headers](#frontend-add-at-your-host) at your host, with your real API URL in `connect-src`. # Deployment # Deployment FastSvelte deploys as three pieces plus a database. A typical production setup maps them to three URLs: | Piece | What it is | Typical URL | | ------------ | --------------------------------------- | ---------------------- | | **API** | FastAPI backend (Docker container) | `api.yourdomain.com` | | **App** | SvelteKit SPA dashboard (static files) | `app.yourdomain.com` | | **Landing** | SvelteKit marketing site (static files) | `yourdomain.com` | | **Database** | PostgreSQL | managed or self-hosted | Anything that can run a Docker container, serve static files, and provide PostgreSQL works, so you can **mix any providers you like**. Below are the most common, straightforward setups. Pick one and you're live. Info Background on the SPA + API pairing itself (CORS, session cookies, and the deploy shapes): [How to use FastAPI with Svelte](https://fastsvelte.dev/fastapi-svelte). ## Common setups Five complete, end-to-end guides. Pick one: - **[Railway](https://docs.fastsvelte.dev/deployment/railway/index.md)**: all-in-one (API + app + landing + Postgres). The simplest, near one-click. - **[DigitalOcean](https://docs.fastsvelte.dev/deployment/digitalocean/index.md)** (App Platform): container + managed Postgres + static sites. - **[Fly.io + Neon + Vercel](https://docs.fastsvelte.dev/deployment/fly-neon-vercel/index.md)** (best-of-breed): API on Fly, Postgres on Neon, app + landing on Vercel. - **[Azure](https://docs.fastsvelte.dev/deployment/azure/index.md)**: Container Apps + PostgreSQL Flexible Server + Static Web Apps (enterprise). - **[Self-Hosting (Docker Compose)](https://docs.fastsvelte.dev/deployment/self-hosting/index.md)**: all three + Postgres on one VPS. They reach the same outcome: point each provider at the right subdomain and set the URLs/CORS so the app can reach the API ([Configuration](https://docs.fastsvelte.dev/reference/configuration/index.md)). ## Every setup needs - **Environment variables**: backend `FS_*` (see [Configuration](https://docs.fastsvelte.dev/reference/configuration/index.md)); the frontend and landing use `PUBLIC_*` (e.g. `PUBLIC_API_BASE_URL`). - **A PostgreSQL database**: put its connection string in `FS_DB_URL`, then run migrations (`./sqitch.sh <env> deploy`). - **The Stripe webhook**: point it at `https://api.yourdomain.com/webhooks/stripe` (see [Billing & Subscriptions](https://docs.fastsvelte.dev/features/billing/index.md)). - **HTTPS**: most platforms provision SSL automatically; when self-hosting, terminate TLS at your reverse proxy. - **A production pass**: review the [Security](https://docs.fastsvelte.dev/features/security/index.md) checklist before launch. # Deploy to Railway Railway is the simplest all-in-one path: it runs your FastAPI container, a managed PostgreSQL, and the static app + landing from one project, redeploying on every git push. **Outcome:** API at `api.yourdomain.com`, app at `app.yourdomain.com`, landing at `yourdomain.com`. ## 1. Create the project + database 1. Create a [Railway](https://railway.app) project from your repo, and it detects `backend/Dockerfile`. 1. Add a **PostgreSQL** service (New → Database → PostgreSQL); Railway provisions its connection string. ## 2. Deploy the backend (API) 1. In the backend service → **Variables**, set the FastSvelte env (full list in [Configuration](https://docs.fastsvelte.dev/reference/configuration/index.md)): - `FS_DB_URL`: reference the Postgres service's connection string - `FS_ENVIRONMENT=prod`, `FS_BASE_API_URL=https://api.yourdomain.com`, `FS_BASE_WEB_URL=https://app.yourdomain.com` - `FS_JWT_SECRET_KEY`, `FS_CRON_SECRET`, plus Stripe/email keys as needed 1. Run migrations once against the prod database with `./sqitch.sh prod deploy` (prod Sqitch target pointed at `FS_DB_URL`), from your local machine or CI. 1. Under **Settings → Networking**, add the custom domain `api.yourdomain.com`. ## 3. Deploy the app + landing (static) `frontend/` and `landing/` are static SvelteKit builds. Add a static service for each that runs `npm install && npm run build` and serves the build output: - Set `PUBLIC_API_BASE_URL=https://api.yourdomain.com` (and any other `PUBLIC_*`) before the build. These are baked in at build time, so they must exist as service variables when the build runs. - Attach `app.yourdomain.com` to the frontend and `yourdomain.com` to the landing. - The frontend is an SPA, so deep links like `/settings` must serve `build/index.html`. Railway's static sites are served by Caddy; if a deep-link refresh 404s, add `try_files {path} /index.html` to the service's `Caddyfile`. Prefer Vercel for the static sites? The Vercel steps in [Fly.io + Neon + Vercel](https://docs.fastsvelte.dev/deployment/fly-neon-vercel/index.md) apply to any backend host. ### Serving the app from a sub-path To serve the app at `yourdomain.com/app` instead of `app.yourdomain.com`: each Railway service has its own domain, so the service that owns `yourdomain.com` (the landing) forwards the prefix. In the landing's `Caddyfile`, add: ```text handle_path /app/* { reverse_proxy https://<frontend-service-domain> } ``` `handle_path` strips the `/app` prefix, which is what the frontend service expects since it serves the build at its own root. The frontend and backend settings that go with this are in [Serving from a Sub-Path](https://docs.fastsvelte.dev/deployment/sub-path/index.md). ## 4. Wire it together - Point DNS for `api`, `app`, and the apex at the Railway domains. - Confirm `FS_BASE_API_URL` / `FS_BASE_WEB_URL` match the live URLs so CORS and cookies work ([Configuration](https://docs.fastsvelte.dev/reference/configuration/index.md)). - Add the Stripe webhook at `https://api.yourdomain.com/webhooks/stripe` ([Billing & Subscriptions](https://docs.fastsvelte.dev/features/billing/index.md)). - Review the [Security](https://docs.fastsvelte.dev/features/security/index.md) checklist before launch. ## Next steps ### Security headers Railway serves static sites with Caddy. Add a `Caddyfile` in `frontend/` with a `header` block: ```text :{$PORT} { root * build try_files {path} /index.html file_server header { Content-Security-Policy "default-src 'self'; connect-src 'self' https://api.yourdomain.com; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" X-Content-Type-Options "nosniff" Referrer-Policy "strict-origin-when-cross-origin" Strict-Transport-Security "max-age=31536000; includeSubDomains" Permissions-Policy "camera=(), microphone=(), geolocation=()" } } ``` Replace `https://api.yourdomain.com` in `connect-src` with your real API URL, or the browser will block the app from calling it. See [Security](https://docs.fastsvelte.dev/features/security/#frontend-add-at-your-host). # Deploy to DigitalOcean DigitalOcean **App Platform** runs all three pieces in one app: the FastAPI container, a managed PostgreSQL, and the static app + landing. (Prefer a single server you control? See [Self-Hosting](https://docs.fastsvelte.dev/deployment/self-hosting/index.md) for a droplet + Docker Compose.) **Outcome:** API at `api.yourdomain.com`, app at `app.yourdomain.com`, landing at `yourdomain.com`. ## 1. Database Create a **Managed PostgreSQL** database (Databases → Create) and copy its connection string for `FS_DB_URL`. ## 2. Backend (API) 1. Create an App from your repo and add a **Service** built from `backend/Dockerfile`. 1. Set environment variables ([Configuration](https://docs.fastsvelte.dev/reference/configuration/index.md)): - `FS_DB_URL`: the managed database connection string - `FS_ENVIRONMENT=prod`, `FS_BASE_API_URL=https://api.yourdomain.com`, `FS_BASE_WEB_URL=https://app.yourdomain.com` - `FS_JWT_SECRET_KEY`, `FS_CRON_SECRET`, plus Stripe/email keys 1. Run migrations against the managed DB: `./sqitch.sh prod deploy` (from a console or your machine, with the prod `FS_DB_URL`). 1. Add the domain `api.yourdomain.com` to the service. ## 3. App + landing (static sites) Add two **Static Site** components from the same repo: - **frontend** (`frontend/`): build `npm run build`; env `PUBLIC_API_BASE_URL=https://api.yourdomain.com` (set as a build-time variable; static builds bake `PUBLIC_*` in at build time); **Catchall document**: `index.html` (the SPA fallback, so deep links like `/settings` resolve); domain `app.yourdomain.com`. - **landing** (`landing/`): build `npm run build`; domain `yourdomain.com`. ### Serving the app from a sub-path To serve the app at `yourdomain.com/app` instead of `app.yourdomain.com`: App Platform routes components of one app by path. Put both static sites in the same app, set the frontend component's **Route** to `/app` (the landing keeps `/`), and set `index.html` as the frontend's catchall document. The frontend and backend settings that go with this are in [Serving from a Sub-Path](https://docs.fastsvelte.dev/deployment/sub-path/index.md). ## 4. Wire it together Point DNS at the App Platform domains, confirm the `FS_BASE_*` URLs match the live ones, add the Stripe webhook at `https://api.yourdomain.com/webhooks/stripe` ([Billing & Subscriptions](https://docs.fastsvelte.dev/features/billing/index.md)), and run the [Security](https://docs.fastsvelte.dev/features/security/index.md) checklist. ## Next steps ### Security headers App Platform Static Sites cannot set custom response headers, so the app's security headers are added at the CDN instead. Put the app behind Cloudflare (free) and set them there: 1. Add `app.yourdomain.com` to a Cloudflare zone and point its DNS at the App Platform domain (proxied). 1. In Cloudflare, go to **Rules → Transform Rules → Modify Response Header** and add one **Set** rule per header: ```text Content-Security-Policy: default-src 'self'; connect-src 'self' https://api.yourdomain.com; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none' X-Content-Type-Options: nosniff Referrer-Policy: strict-origin-when-cross-origin Permissions-Policy: camera=(), microphone=(), geolocation=() ``` 1. Enable HSTS under **SSL/TLS → Edge Certificates → HTTP Strict Transport Security (HSTS)** rather than as a header rule. Replace `https://api.yourdomain.com` in `connect-src` with your real API URL, or the browser will block the app from calling it. See [Security](https://docs.fastsvelte.dev/features/security/#frontend-add-at-your-host). # Deploy: Fly.io + Neon + Vercel A best-of-breed split: the **API on Fly.io** (global containers), **PostgreSQL on Neon** (serverless), and the **app + landing on Vercel** (static, edge CDN). Pick this when you want each piece on the platform that does it best. **Outcome:** API at `api.yourdomain.com`, app at `app.yourdomain.com`, landing at `yourdomain.com`. ## 1. Database: Neon 1. Create a project at [Neon](https://neon.tech) and copy the **connection string** (use the pooled one for serverless). 1. You'll set it as `FS_DB_URL` on the API in the next step. ## 2. API: Fly.io 1. Install [flyctl](https://fly.io/docs/flyctl/install/), then run `fly launch` in `backend/` (it detects the Dockerfile, but don't deploy yet). 1. Set secrets ([Configuration](https://docs.fastsvelte.dev/reference/configuration/index.md)): ```bash fly secrets set \ FS_DB_URL="<neon-connection-string>" \ FS_ENVIRONMENT=prod \ FS_BASE_API_URL=https://api.yourdomain.com \ FS_BASE_WEB_URL=https://app.yourdomain.com \ FS_JWT_SECRET_KEY=... FS_CRON_SECRET=... ``` 1. `fly deploy`, then run migrations against Neon: `./sqitch.sh prod deploy` (prod target = `FS_DB_URL`). 1. Add the domain: `fly certs add api.yourdomain.com`, then point DNS at Fly. ## 3. App + landing: Vercel Create two Vercel projects from the same repo: - **frontend**: root directory `frontend/`, build `npm run build`, env `PUBLIC_API_BASE_URL=https://api.yourdomain.com`; domain `app.yourdomain.com`. - **landing**: root directory `landing/`, build `npm run build`; domain `yourdomain.com`. Vercel auto-deploys on push and provisions SSL. No other configuration is needed: each directory ships a `vercel.json` that pins the output directory and handles routing (the SPA fallback for the app, clean URLs for the landing). Environment variables set in the Vercel project are available at build time, which is when a static build needs them. ### Serving the app from a sub-path To serve the app at `yourdomain.com/app` instead of `app.yourdomain.com`: Vercel serves one project per domain, so the landing project (which owns `yourdomain.com`) forwards the prefix. In the landing's `vercel.json`, add a rewrite that preserves the path: ```json { "rewrites": [{ "source": "/app/:path*", "destination": "https://<frontend-deployment>/app/:path*" }] } ``` The frontend and backend settings that go with this are in [Serving from a Sub-Path](https://docs.fastsvelte.dev/deployment/sub-path/index.md). ## 4. Wire it together - DNS: `api` → Fly.io; `app` and the apex → Vercel. - Confirm `FS_BASE_API_URL` / `FS_BASE_WEB_URL` match the live URLs so CORS and cookies work ([Configuration](https://docs.fastsvelte.dev/reference/configuration/index.md)). - Stripe webhook → `https://api.yourdomain.com/webhooks/stripe` ([Billing & Subscriptions](https://docs.fastsvelte.dev/features/billing/index.md)). - Review the [Security](https://docs.fastsvelte.dev/features/security/index.md) checklist before launch. ## Next steps ### Security headers The kit ships `frontend/vercel.json` (it pins the output directory and provides the SPA fallback). Add a `headers` key to it: ```json { "headers": [ { "source": "/(.*)", "headers": [ { "key": "Content-Security-Policy", "value": "default-src 'self'; connect-src 'self' https://api.yourdomain.com; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" }, { "key": "X-Content-Type-Options", "value": "nosniff" }, { "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" }, { "key": "Strict-Transport-Security", "value": "max-age=31536000; includeSubDomains" }, { "key": "Permissions-Policy", "value": "camera=(), microphone=(), geolocation=()" } ] } ] } ``` Replace `https://api.yourdomain.com` in `connect-src` with your real API URL, or the browser will block the app from calling it. See [Security](https://docs.fastsvelte.dev/features/security/#frontend-add-at-your-host). # Deploy to Azure Deploy FastSvelte using Azure's modern container and serverless services. This guide covers the essential Azure resources you'll need. ## What You'll Use - **Azure Container Apps** - Run your FastAPI backend container - **PostgreSQL Flexible Server** - Managed PostgreSQL database - **Azure Static Web Apps** - Host your SvelteKit frontend and landing page - **Azure Container Registry** - Store your Docker images (built-in) ## Prerequisites - Azure account with active subscription - Azure CLI installed ([install guide](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli)) - Docker installed locally - Your FastSvelte codebase ready ## Cost Estimate For a small production app: - Container Apps: ~$15-30/month - PostgreSQL Flexible Server: ~$50-100/month (can use [Neon](https://neon.tech) for ~$20/month instead) - Static Web Apps: Free tier available, ~$10/month for standard - Container Registry: ~$5/month **Total:** ~$70-145/month (or ~$50/month with Neon for database) ## Architecture ```text Internet ↓ Azure Front Door (optional CDN) ↓ Static Web Apps (Frontend) → Container Apps (Backend) → PostgreSQL ``` ## Deployment Steps ### 1. Login to Azure ```bash az login az account set --subscription "Your Subscription Name" ``` ### 2. Create Resource Group ```bash # Create resource group in your preferred region az group create \ --name fastsvelte-prod-rg \ --location eastus ``` ### 3. Create PostgreSQL Database ```bash # Create PostgreSQL Flexible Server az postgres flexible-server create \ --resource-group fastsvelte-prod-rg \ --name fastsvelte-prod-db \ --location eastus \ --admin-user fsadmin \ --admin-password <STRONG-PASSWORD> \ --sku-name Standard_B1ms \ --storage-size 32 \ --version 16 # Allow Azure services to connect az postgres flexible-server firewall-rule create \ --resource-group fastsvelte-prod-rg \ --name fastsvelte-prod-db \ --rule-name AllowAzureServices \ --start-ip-address 0.0.0.0 \ --end-ip-address 0.0.0.0 # Create database az postgres flexible-server db create \ --resource-group fastsvelte-prod-rg \ --server-name fastsvelte-prod-db \ --database-name fastsvelte ``` **Alternative:** Use [Neon](https://neon.tech) or [Supabase](https://supabase.com) for a more affordable managed Postgres option. ### 4. Run Database Migrations ```bash cd db # Add production database URL to .env echo 'DATABASE_URL_PROD="postgres://fsadmin:<PASSWORD>@fastsvelte-prod-db.postgres.database.azure.com/fastsvelte?sslmode=require"' >> .env # Deploy migrations ./sqitch.sh prod deploy # Verify ./sqitch.sh prod status ``` ### 5. Create Admin User ```bash cd ../backend # Create production admin account uv run scripts/create_admin.py \ --env prod \ --password <ADMIN-PASSWORD> \ --domain yourdomain.com ``` ### 6. Create Container Registry ```bash # Create Azure Container Registry az acr create \ --resource-group fastsvelte-prod-rg \ --name faststvelteregistry \ --sku Basic \ --admin-enabled true # Get registry credentials az acr credential show --name faststvelteregistry ``` ### 7. Build and Push Backend Container ```bash # Login to registry az acr login --name faststvelteregistry # Build and push backend image cd backend docker build -t faststvelteregistry.azurecr.io/fastsvelte-api:latest . docker push faststvelteregistry.azurecr.io/fastsvelte-api:latest ``` ### 8. Create Container Apps Environment ```bash # Create Container Apps Environment az containerapp env create \ --resource-group fastsvelte-prod-rg \ --name fastsvelte-prod-env \ --location eastus ``` ### 9. Deploy Backend API ```bash # Get registry credentials REGISTRY_USERNAME=$(az acr credential show --name faststvelteregistry --query username -o tsv) REGISTRY_PASSWORD=$(az acr credential show --name faststvelteregistry --query passwords[0].value -o tsv) # Create Container App az containerapp create \ --resource-group fastsvelte-prod-rg \ --name fastsvelte-prod-api \ --environment fastsvelte-prod-env \ --image faststvelteregistry.azurecr.io/fastsvelte-api:latest \ --target-port 3100 \ --ingress external \ --min-replicas 1 \ --max-replicas 5 \ --cpu 1.0 \ --memory 2Gi \ --registry-server faststvelteregistry.azurecr.io \ --registry-username $REGISTRY_USERNAME \ --registry-password $REGISTRY_PASSWORD ``` ### 10. Configure Environment Variables ```bash # Set environment variables for the API az containerapp update \ --resource-group fastsvelte-prod-rg \ --name fastsvelte-prod-api \ --set-env-vars \ FS_ENVIRONMENT=prod \ FS_DB_URL="postgres://fsadmin:<PASSWORD>@fastsvelte-prod-db.postgres.database.azure.com/fastsvelte?sslmode=require" \ FS_JWT_SECRET_KEY="<RANDOM-SECRET-KEY>" \ FS_BASE_API_URL="https://<YOUR-CONTAINER-APP-URL>" \ FS_BASE_WEB_URL="https://app.yourdomain.com" \ FS_CORS_ORIGINS="https://app.yourdomain.com,https://yourdomain.com" \ FS_STRIPE_API_KEY="<YOUR-STRIPE-KEY>" \ FS_STRIPE_WEBHOOK_SECRET="<YOUR-STRIPE-WEBHOOK-SECRET>" \ FS_SENDGRID_API_KEY="<YOUR-SENDGRID-KEY>" ``` Get your Container App URL: ```bash az containerapp show \ --resource-group fastsvelte-prod-rg \ --name fastsvelte-prod-api \ --query properties.configuration.ingress.fqdn \ --output tsv ``` ### 11. Deploy Frontend to Static Web Apps ```bash # Create Static Web App for frontend az staticwebapp create \ --resource-group fastsvelte-prod-rg \ --name fastsvelte-prod-app \ --location eastus \ --source https://github.com/yourusername/your-fastsvelte-fork \ --branch main \ --app-location "frontend" \ --output-location "build" \ --login-with-github ``` This will: 1. Connect to your GitHub repository 1. Set up GitHub Actions for automatic deployments 1. Build and deploy your frontend **Configure environment variables in the deployment workflow.** The app is a static build: `PUBLIC_*` variables are baked in at build time, so they must be present when GitHub Actions runs the build. (Portal application settings only apply at runtime and have no effect on a static site.) Edit the workflow that `az staticwebapp create` generated (`.github/workflows/azure-static-web-apps-*.yml`) and add the variables to the deploy step: ```yaml - name: Build And Deploy uses: Azure/static-web-apps-deploy@v1 env: PUBLIC_API_BASE_URL: https://your-container-app-url PUBLIC_APP_NAME: YourApp ``` Deep links like `app.yourdomain.com/settings` work out of the box: the kit ships `frontend/staticwebapp.config.json` with a `navigationFallback` rewrite to the SPA's `index.html`. ### 12. Deploy Landing Page ```bash # Create Static Web App for landing az staticwebapp create \ --resource-group fastsvelte-prod-rg \ --name fastsvelte-prod-landing \ --location eastus \ --source https://github.com/yourusername/your-fastsvelte-fork \ --branch main \ --app-location "landing" \ --output-location "build" \ --login-with-github ``` ## Custom Domains (Optional) ### For Container App (API) ```bash az containerapp hostname bind \ --resource-group fastsvelte-prod-rg \ --name fastsvelte-prod-api \ --hostname api.yourdomain.com ``` ### For Static Web Apps ```bash az staticwebapp hostname set \ --resource-group fastsvelte-prod-rg \ --name fastsvelte-prod-app \ --hostname app.yourdomain.com ``` ### Serving the app from a sub-path To serve the app at `yourdomain.com/app` instead of `app.yourdomain.com`: Static Web Apps serve one site per domain, so put the sites behind Azure Front Door, route `/app/*` to the app's Static Web App, and use a Front Door origin-path rewrite to strip the `/app` prefix before it reaches the Static Web App. The frontend and backend settings that go with this are in [Serving from a Sub-Path](https://docs.fastsvelte.dev/deployment/sub-path/index.md). ## Scaling Configuration Container Apps auto-scale based on HTTP requests by default. Configure custom scaling: ```bash az containerapp update \ --resource-group fastsvelte-prod-rg \ --name fastsvelte-prod-api \ --min-replicas 2 \ --max-replicas 10 \ --scale-rule-name "http-scale" \ --scale-rule-type "http" \ --scale-rule-metadata "concurrentRequests=50" ``` ## Monitoring & Logs View Container App logs: ```bash # Stream live logs az containerapp logs show \ --resource-group fastsvelte-prod-rg \ --name fastsvelte-prod-api \ --follow # Recent logs az containerapp logs show \ --resource-group fastsvelte-prod-rg \ --name fastsvelte-prod-api \ --tail 100 ``` ## Updating Your Application ### Update Backend ```bash # Build new image cd backend docker build -t faststvelteregistry.azurecr.io/fastsvelte-api:v2 . docker push faststvelteregistry.azurecr.io/fastsvelte-api:v2 # Update Container App az containerapp update \ --resource-group fastsvelte-prod-rg \ --name fastsvelte-prod-api \ --image faststvelteregistry.azurecr.io/fastsvelte-api:v2 ``` ### Update Frontend Frontend updates automatically via GitHub Actions when you push to your repository. ## Security Best Practices 1. **Use Azure Key Vault** for secrets instead of environment variables: ```bash az keyvault create \ --resource-group fastsvelte-prod-rg \ --name fastsvelte-vault ``` 1. **Enable managed identity** for Container Apps to access Key Vault 1. **Restrict database access** to only Container Apps IP range 1. **Enable Azure Front Door** for DDoS protection and CDN 1. **Set up Azure Monitor** for alerts on errors and performance ## Troubleshooting **Container App won't start:** ```bash # Check logs az containerapp logs show \ --resource-group fastsvelte-prod-rg \ --name fastsvelte-prod-api \ --follow ``` **Database connection issues:** - Verify firewall rules allow Container Apps - Check connection string format - Ensure SSL mode is set to `require` **Static Web App build fails:** - Check GitHub Actions logs in your repository - Verify `app-location` and `output-location` paths - Ensure Node.js version compatibility ## Cost Optimization Tips 1. **Use Neon/Supabase for database** - Save ~$30-80/month compared to Azure PostgreSQL 1. **Start with 1 replica** - Scale up only when needed 1. **Use consumption-based Container Apps** - Pay only for actual usage 1. **Combine frontend and landing** - Host both on same Static Web App if possible 1. **Use Azure Reservations** - Save up to 38% with 1-year commitment for predictable workloads ## Next Steps ### Security headers The kit ships `frontend/staticwebapp.config.json` (it provides the SPA fallback for deep links). Add security headers to it with a `globalHeaders` block: ```json { "globalHeaders": { "Content-Security-Policy": "default-src 'self'; connect-src 'self' https://api.yourdomain.com; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'", "X-Content-Type-Options": "nosniff", "Referrer-Policy": "strict-origin-when-cross-origin", "Strict-Transport-Security": "max-age=31536000; includeSubDomains", "Permissions-Policy": "camera=(), microphone=(), geolocation=()" } } ``` Replace `https://api.yourdomain.com` in `connect-src` with your real API URL, or the browser will block the app from calling it. See [Security](https://docs.fastsvelte.dev/features/security/#frontend-add-at-your-host). ### Other - Set up monitoring and alerts - Configure backups for database - Set up CI/CD pipeline for automated deployments - Configure custom domains - Set up Azure Front Door for CDN ## Useful Commands ```bash # View all resources az resource list --resource-group fastsvelte-prod-rg --output table # Get Container App URL az containerapp show \ --resource-group fastsvelte-prod-rg \ --name fastsvelte-prod-api \ --query properties.configuration.ingress.fqdn # Scale Container App az containerapp update \ --resource-group fastsvelte-prod-rg \ --name fastsvelte-prod-api \ --min-replicas 2 \ --max-replicas 10 # Delete everything (careful!) az group delete --name fastsvelte-prod-rg ``` # Deploy with Docker Compose Deploy FastSvelte on your own server using Docker Compose for full control over your infrastructure. ## What You'll Need - A Linux server (Ubuntu 22.04+ recommended) - Docker and Docker Compose installed - A domain name (optional but recommended) - Basic Linux and DevOps knowledge ## Cost Estimate - VPS (2GB RAM): $5-12/month - Domain: $10-15/year - **Total:** ~$5-15/month Why Docker Compose? - **Full control**: your server, your rules - **Cost-effective**: the cheapest option for a single deployment - **Simple**: one configuration file - **Portable**: works on any Docker host - **No vendor lock-in**: standard Docker setup ## Prerequisites ```bash # Install Docker curl -fsSL https://get.docker.com | sh # Install Docker Compose sudo apt-get update sudo apt-get install docker-compose-plugin ``` ## The Backend Runs in Docker Compose The kit ships `backend/docker-compose.yml`, which runs the two server pieces: PostgreSQL and the FastAPI backend. ```yaml # backend/docker-compose.yml (shipped with the kit, simplified) services: db: image: postgres:17 volumes: - db-data:/var/lib/postgresql/data api: build: context: . depends_on: - db ports: - "8000:3100" env_file: - .env ``` The frontend and landing are **not** compose services. They are static builds (see [Architecture](https://docs.fastsvelte.dev/reference/architecture/#rendering-model-app-and-landing)): the app is an SPA with an `index.html` fallback, the landing is prerendered HTML. [Deploy the Frontends](#deploy-the-frontends-static-files) below covers serving them. ## Deployment Steps ### 1. Clone Your Repository ```bash git clone <your-fastsvelte-repo> cd fastsvelte ``` ### 2. Configure Environment ```bash cd backend cp .env.example .env # Fill in the secrets: database credentials, session secret, Stripe keys, ... ``` ### 3. Deploy ```bash # Start db + api (from backend/) docker compose up -d # View logs docker compose logs -f ``` ### 4. Run Migrations ```bash # Access backend container docker-compose exec api bash # Run migrations cd /app # Your migration commands here ``` ## Deploy the Frontends (Static Files) `frontend/` and `landing/` build to plain static files, and `PUBLIC_*` variables are baked in at build time, so set them before `npm run build`. There are two ways to serve the output; Option A is simpler. ### Option A: nginx serves the builds (recommended) Build on the server (Node 24+) or in CI, then copy the output into place: ```bash sudo mkdir -p /var/www/fastsvelte/app /var/www/fastsvelte/landing cd frontend npm ci PUBLIC_API_BASE_URL=https://api.yourdomain.com npm run build sudo cp -r build/. /var/www/fastsvelte/app/ cd ../landing npm ci npm run build sudo cp -r build/. /var/www/fastsvelte/landing/ ``` Rebuild and re-copy whenever the code or any `PUBLIC_*` value changes. The landing is prerendered, so marketing content edits also need a rebuild. ### Option B: Frontends in Docker Prefer everything containerized? Create `frontend/Dockerfile` (the kit does not ship one): ```dockerfile FROM node:24-alpine AS build WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . ARG PUBLIC_API_BASE_URL ENV PUBLIC_API_BASE_URL=$PUBLIC_API_BASE_URL RUN npm run build FROM nginx:alpine COPY --from=build /app/build /usr/share/nginx/html COPY <<'EOF' /etc/nginx/conf.d/default.conf server { listen 80; root /usr/share/nginx/html; try_files $uri /index.html; } EOF ``` `PUBLIC_API_BASE_URL` must be a **build arg** (under `build.args` in your compose service), not a runtime variable: a static build cannot read the environment after it is built. Repeat for `landing/`, changing the nginx line to `try_files $uri $uri.html =404;` (the landing is prerendered HTML, not an SPA). With this option, the nginx blocks below `proxy_pass` to the containers instead of serving files. ## Setting Up Nginx nginx fronts everything: it proxies the API container and, with Option A, serves the static builds directly: ```nginx # /etc/nginx/sites-available/fastsvelte server { server_name api.yourdomain.com; location / { proxy_pass http://localhost:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } } # App (static SPA): serve files, fall back to index.html for deep links server { server_name app.yourdomain.com; root /var/www/fastsvelte/app; try_files $uri /index.html; } # Landing (prerendered): serve files, map /about to about.html server { server_name yourdomain.com; root /var/www/fastsvelte/landing; try_files $uri $uri.html =404; } ``` With Option B, replace the two static blocks with `proxy_pass` blocks pointing at the frontend and landing containers' published ports. ### Security headers In the `app.yourdomain.com` server block above, add the app's security headers: ```nginx server { server_name app.yourdomain.com; add_header Content-Security-Policy "default-src 'self'; connect-src 'self' https://api.yourdomain.com; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always; add_header X-Content-Type-Options "nosniff" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; root /var/www/fastsvelte/app; try_files $uri /index.html; } ``` Replace `https://api.yourdomain.com` in `connect-src` with your real API URL, or the browser will block the app from calling it. See [Security](https://docs.fastsvelte.dev/features/security/#frontend-add-at-your-host). ### Serving the app from a sub-path Sub-path serving is simplest with Option B, where the app container keeps serving the build at its root. To serve the app at `yourdomain.com/app` instead of its own subdomain, drop the `app.yourdomain.com` server block and add a location to the main site's block that proxies to the app container: ```nginx location /app/ { proxy_pass http://localhost:80/; # trailing slash strips the /app prefix proxy_set_header Host $host; } ``` The container keeps serving the build at its root with its own `index.html` fallback; the stripped prefix makes that work unchanged. The frontend and backend settings that go with this are in [Serving from a Sub-Path](https://docs.fastsvelte.dev/deployment/sub-path/index.md). ## SSL with Let's Encrypt ```bash # Install Certbot sudo apt-get install certbot python3-certbot-nginx # Get certificates sudo certbot --nginx -d api.yourdomain.com -d app.yourdomain.com -d yourdomain.com ``` ## Maintenance ### Backups ```bash # Backup database docker-compose exec db pg_dump -U postgres fastsvelte > backup.sql # Backup volumes docker run --rm -v fastsvelte_postgres_data:/data -v $(pwd):/backup ubuntu tar czf /backup/postgres_backup.tar.gz /data ``` ### Updates ```bash # Pull latest changes git pull # Rebuild and restart the backend (cd backend && docker compose up -d --build) # Rebuild and redeploy the frontends (Option A) (cd frontend && npm run build) && sudo cp -r frontend/build/. /var/www/fastsvelte/app/ (cd landing && npm run build) && sudo cp -r landing/build/. /var/www/fastsvelte/landing/ ``` ### Monitoring ```bash # View logs docker-compose logs -f # Check container status docker-compose ps # View resource usage docker stats ``` ## Recommended VPS Providers - **Hetzner** - €4-20/month, excellent EU performance - **DigitalOcean** - $6-20/month, reliable and well-documented - **Vultr** - $6-20/month, global locations - **Linode** - $5-20/month, good performance ## Alternative: Use Coolify If you want a GUI for managing Docker deployments, consider [Coolify](https://coolify.io), a self-hostable web interface for Docker Compose deployments. ## Security Considerations - Keep Docker and system packages updated - Use strong passwords and secrets - Configure firewall (ufw or iptables) - Set up automated backups - Monitor logs for suspicious activity - Use fail2ban for SSH protection ______________________________________________________________________ Docker Compose is best for: single deployments, learning, development servers, or when you want maximum control at minimum cost. # Serving the app from a sub-path The standard setups serve the app from its own subdomain (`app.yourdomain.com`). If you serve it from a path prefix instead, such as `yourdomain.com/app`, two settings change: one in the frontend, one in the backend. ## 1. Frontend: set `paths.base` In `frontend/svelte.config.js`: ```js const config = { kit: { adapter: adapter(), paths: { base: '/app' } } }; ``` No trailing slash. Every internal link, redirect, and image in the app goes through SvelteKit's `resolve()` and `asset()` helpers, so all of them follow this setting. The eslint rule `svelte/no-navigation-without-resolve` keeps it that way: a hardcoded `<a href="/billing">` fails lint. ## 2. Backend: include the path in `FS_BASE_WEB_URL` ```bash FS_BASE_WEB_URL=https://yourdomain.com/app ``` No trailing slash here either. Every link the backend hands out is built on this value: password reset and verification emails, invitation links, the OAuth redirect after Google sign-in, and the Stripe portal return URL. ## 3. Host: serve the build under the prefix The app must be reachable under `/app`, and every `/app/*` path that is not a real file must answer with the app's `index.html` (HTTP 200, not a 404 or redirect). This step lives in your hosting setup, and each deployment guide has a **Serving the app from a sub-path** section with the exact change for that stack: - [Self-Hosting](https://docs.fastsvelte.dev/deployment/self-hosting/#serving-the-app-from-a-sub-path) (nginx location block) - [Railway](https://docs.fastsvelte.dev/deployment/railway/#serving-the-app-from-a-sub-path) (landing's Caddyfile) - [DigitalOcean](https://docs.fastsvelte.dev/deployment/digitalocean/#serving-the-app-from-a-sub-path) (component Route setting) - [Fly.io + Neon + Vercel](https://docs.fastsvelte.dev/deployment/fly-neon-vercel/#serving-the-app-from-a-sub-path) (landing's vercel.json rewrite) - [Azure](https://docs.fastsvelte.dev/deployment/azure/#serving-the-app-from-a-sub-path) (Front Door path routing) ## What does not change - **`PUBLIC_API_BASE_URL`**: the backend's absolute URL, independent of where the frontend lives. - **CORS**: origins are scheme, host, and port. A path prefix plays no role. - **Cookies**: the session cookie is set with `path=/` and works under any prefix. - **Google OAuth console**: the authorized redirect URI points at the API, not the app. ## Verify locally Build and preview with the base set, then click through login, dashboard, and billing. This uses `npm run preview` rather than the usual `npm run dev` on purpose: the dev server honors the base too, but preview serves the actual production build, which is what your host will serve. ```bash cd frontend npm run build npm run preview # visit http://localhost:4173/app/login ``` # Reference # Configuration Backend settings load from `backend/.env` with the `FS_` prefix ([Pydantic Settings](https://docs.pydantic.dev/latest/concepts/pydantic_settings/)). Core variables, mostly generated by `init.py`: - `FS_APP_NAME`: application name - `FS_MODE`: `b2c` or `b2b` (see [Multi-Tenancy](https://docs.fastsvelte.dev/features/multi-tenancy/index.md)) - `FS_ENVIRONMENT`: `dev`, `beta`, or `prod` - `FS_DB_URL` / `FS_DB_SCHEMA`: database connection - `FS_BASE_WEB_URL` / `FS_BASE_API_URL`: frontend and backend URLs - `FS_JWT_SECRET_KEY` / `FS_CRON_SECRET`: auto-generated secrets See the `.env.example` in each directory (`backend/`, `frontend/`, `landing/`, `backend/db/`) for the full list. ## Environment ```bash FS_ENVIRONMENT="dev" # dev | beta | prod; affects cookies, CORS, and defaults FS_BASE_WEB_URL="http://localhost:5173" FS_BASE_API_URL="http://localhost:8000" ``` CORS origins are derived per environment in `backend/app/config/settings.py` (`cors_origins`). Cookie security (`Secure`, `SameSite`) tightens automatically outside `dev`. See [Security](https://docs.fastsvelte.dev/features/security/index.md). ## Background jobs (cron) Session cleanup and other scheduled work run via the cron endpoints, authenticated with a shared secret: ```bash FS_CRON_SECRET="your-secure-cron-secret" FS_CRON_SESSION_RETENTION_DAYS=7 ``` ## Rate limiting Auth and email endpoints are rate limited out of the box (see [Security](https://docs.fastsvelte.dev/features/security/#rate-limiting)). Each container is a single process, so the in-memory default is fine for one instance; point at a shared store if you run multiple instances so the limit holds across them: ```bash FS_RATE_LIMIT_STORAGE_URI="async+memory://" # default; per-instance counters # FS_RATE_LIMIT_STORAGE_URI="async+redis://host:6379/0" # shared across instances (needs coredis) ``` ## Integrations Provider setup lives with each feature: [Email](https://docs.fastsvelte.dev/features/email/index.md), [Billing & Subscriptions](https://docs.fastsvelte.dev/features/billing/index.md), [Google OAuth](https://docs.fastsvelte.dev/features/google-oauth/index.md), and [AI](https://docs.fastsvelte.dev/features/ai/index.md). # Architecture This document explains how [FastSvelte](https://fastsvelte.dev) is structured and the reasoning behind key architectural decisions. ## 1. The Monorepo Structure FastSvelte has four parts: ```text fastsvelte/ ├── backend/ # FastAPI + Python (your API) ├── frontend/ # SvelteKit + TypeScript (admin dashboard) ├── landing/ # SvelteKit (marketing site) └── backend/db/ # PostgreSQL + Sqitch (database migrations) ``` ### Why a monorepo? FastSvelte uses a monorepo because it's a tightly coupled fullstack application where the backend and frontend are designed to work together. When the backend API changes, the frontend needs to change with it - keeping them in separate repositories would mean managing dependencies, versioning, and synchronization across repos. A monorepo allows atomic commits that update the database schema, backend logic, and frontend UI simultaneously, ensuring the entire system stays in sync. This simplifies development with a single clone and unified tooling. ### Design Philosophy: Minimal Dependencies, Maximum Flexibility FastSvelte intentionally avoids many popular libraries and frameworks that other starter kits include. Instead of bundling heavy abstractions, it sticks to proven, essential tools with minimal dependencies. **Why stay lean?** Adding a library later is straightforward - removing one that's baked into the starter kit is painful. As a starter kit, FastSvelte's job is to provide a solid foundation, not to make architectural decisions for you. This approach makes the codebase highly customizable. Build exactly what you need, add libraries as your requirements become clear, and maintain full control over your architecture. ______________________________________________________________________ ## 2. End-to-End Request Flow Let's trace what happens when a user creates a project in your SaaS app. (This example follows the [Adding a Feature tutorial](https://docs.fastsvelte.dev/guides/adding-a-feature/index.md), where you build a project management feature.) ### High-Level Flow ``` sequenceDiagram participant User participant Frontend as Frontend<br/>(SvelteKit) participant Backend as Backend<br/>(FastAPI) User->>Frontend: Fill form & click "Create Project" Frontend->>Backend: POST /api/projects Backend->>Backend: Validate, auth, save to DB Backend-->>Frontend: ProjectResponse (JSON) Frontend->>Frontend: Update UI Frontend-->>User: Show success ``` **The flow:** 1. **User** fills out a form and clicks "Create Project" 1. **Frontend** sends API request with project data 1. **Backend** validates, authenticates, and saves to database 1. **Response** flows back: backend → frontend → user sees success That's the high-level flow. Now let's see how each piece is built. ______________________________________________________________________ ## 3. Backend: Layered Architecture The backend is where all the heavy lifting happens. External services like Stripe, SendGrid, and Google OAuth integrate here. The frontend stays thin - it's purely a presentation layer that talks to the backend API. The backend separates concerns into layers. Here's how a request flows through them: ### Backend Request Flow ``` sequenceDiagram participant Route as Route Layer<br/>(HTTP) participant Service as Service Layer<br/>(Business Logic) participant Repo as Repository<br/>(Data Access) participant DB as PostgreSQL Route->>Route: Validate request & auth Route->>Service: create_project(org_id, user_id, data) Service->>Service: Check quotas & validate Service->>Repo: create_project(org_id, user_id, data) Repo->>DB: INSERT INTO project... DB-->>Repo: Return project ID Repo->>DB: SELECT * FROM project WHERE id = ? DB-->>Repo: Return project data Repo-->>Service: Project entity Service-->>Route: Project entity Route-->>Route: Convert to JSON response ``` **Each layer has a specific job:** - **Route** - Handles HTTP (validation, auth, response formatting) - **Service** - Business logic (quotas, permissions, workflows) - **Repository** - Database access (SQL queries) - **Database** - Data storage ### Directory Structure ```text app/ ├── main.py # Starts everything ├── config/ # Settings & dependency injection ├── api/route/ # HTTP endpoints ├── service/ # Business logic ├── data/repo/ # Database queries ├── model/ # Request/response shapes └── util/ # Auth, email, etc. ``` Keep Layers Separated Don't leak concerns between layers. Services shouldn't know about HTTP status codes or request objects. Repositories shouldn't contain business logic. **Bad**: the service returns an HTTP exception: ```python # Wrong: the service layer shouldn't know about HTTP async def create_user(self, email: str): if self.user_repo.exists(email): raise HTTPException(status_code=409, detail="User exists") ``` **Good**: the service throws a domain exception, the route handles HTTP: ```python # Right: the service throws a domain exception async def create_user(self, email: str): if self.user_repo.exists(email): raise UserAlreadyExistsException(email) # Right: the route converts it to HTTP @router.post("/users") async def create_user_route(data: UserCreate): try: return await user_service.create_user(data.email) except UserAlreadyExistsException as e: raise HTTPException(status_code=409, detail=str(e)) ``` ### Why raw SQL instead of an ORM? ORMs add complexity. You learn the ORM's query language, debug what SQL it generates, then eventually write raw SQL anyway for performance. With raw SQL in repositories, you see exactly what runs and optimize directly. Raw SQL is also easier for LLMs to generate and reason about, making AI-assisted development smoother. Beyond technical considerations, this choice aligns with FastSvelte's [design philosophy](#design-philosophy-minimal-dependencies-maximum-flexibility) of staying lean. Adding an ORM later is straightforward when your project needs it - removing one that's baked into the starter kit is painful. You maintain full control over data access patterns and can choose SQLAlchemy, Prisma, or any other tool based on your actual requirements. ### Why dependency injection? Dependency injection eliminates repetitive boilerplate and centralizes configuration. Instead of manually constructing dependencies in every route, they're wired up once and injected automatically. In FastSvelte, all objects are wired up in one place (`app/config/container.py`): ```python # Define everything once project_repo = providers.Singleton(ProjectRepo, db_config=db_config) project_service = providers.Singleton(ProjectService, project_repo=project_repo) ``` Then use them anywhere: ```python async def create_project( project_service: ProjectService = Depends() # Injected automatically ): ... ``` This centralization provides several benefits. Object lifecycles (singleton vs factory) are explicit and visible in one file - no hunting through the codebase to determine if a service creates new instances or reuses one. Configuration changes propagate automatically without touching route code. Testing becomes straightforward by swapping implementations in the container rather than modifying dozens of files. ______________________________________________________________________ ## 4. Database: Multi-Tenant PostgreSQL All data is scoped to an **organization** (the tenant boundary), so the schema serves both individual users and teams without changing. Migrations are plain SQL managed with **Sqitch**, no ORM. The mode (`b2c` / `b2b`) is set by `FS_MODE` and changes only application logic, not the schema. See **[Database](https://docs.fastsvelte.dev/features/database/index.md)** for the schema, `db_config`, and the Sqitch workflow, and **[Multi-Tenancy](https://docs.fastsvelte.dev/features/multi-tenancy/index.md)** for the organization, role, and invitation model. ______________________________________________________________________ ## 5. Frontend: Type-Safe SvelteKit The frontend is a SvelteKit SPA (Single Page Application) that stays thin by delegating all business logic to the backend. It uses Svelte 5 runes for reactivity and maintains type safety through auto-generated API clients. **Directory structure:** ```text src/ ├── routes/ │ ├── (auth)/ # Login, signup (public pages) │ ├── (protected)/ # Dashboard, settings (requires authentication) │ └── +layout.svelte # Global layout wrapper ├── lib/ │ ├── api/gen/ # Auto-generated TypeScript API client (Orval) │ ├── auth/ # Session management with Svelte stores │ ├── components/ # Reusable UI components │ ├── context/ # Application-wide context providers │ ├── config/ # Configuration and constants │ └── util/ # Helper functions and utilities ``` **Key features:** - **Route-based authentication**: Routes in `(protected)/` automatically check for valid sessions - **Auto-generated API client**: TypeScript types generated from OpenAPI spec ensure compile-time safety - **Svelte 5 runes**: Modern reactivity with `$state`, `$derived`, and `$effect` for local component state - **TailwindCSS + DaisyUI**: Utility-first styling with pre-built component themes ### Rendering model: app and landing FastSvelte ships two SvelteKit projects that render differently on purpose: | | App (`frontend/`) | Landing (`landing/`) | | ---------------- | ------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ | | **Mode** | SPA (`ssr: false`) | SSG (`ssr: true` + `prerender = true`) | | **Build output** | Static files with an `index.html` fallback | Static HTML per route, with real content | | **Why** | Lives behind auth, so there is nothing for crawlers to index. Client-side rendering keeps session handling simple. | Marketing pages live or die by SEO. Crawlers get complete HTML without running JavaScript. | Both projects build to plain static files, so **the only server in any deployment is the FastAPI backend**. There is no Node tier to run, scale, or pay for. Deep links like `/settings` still work on a static host because the deploy configs shipped with the kit (`vercel.json`, `staticwebapp.config.json`) rewrite unknown paths to the SPA fallback. Prerendering freezes the landing's content at build time. Editing marketing copy means rebuild and redeploy, and `PUBLIC_*` variables are baked in during the build (set them in CI, not in your host's runtime settings). **If your landing outgrows static.** Prerendering is ordinary server-side rendering that runs once at build, so the landing's code stays fully server-renderable. If you later need per-request rendering (personalization, instant-publish content), swap `adapter-static` for `adapter-node` or `adapter-vercel` in `landing/svelte.config.js` and remove the `prerender` flag from `src/routes/+layout.ts`. That is a two-line config change, not a rewrite. Forms like the newsletter signup never need it: point them at a FastAPI endpoint from the client. ### Auto-generated API client The frontend's TypeScript API client is generated from the backend's OpenAPI spec, so a backend change surfaces as a compile-time error in the frontend. See **[Type-Safe API Client (Orval)](https://docs.fastsvelte.dev/guides/orval/index.md)**. ### Data loading: `+page.ts` vs `onMount` Pages fetch their data in a `+page.ts` **load function**, not in `onMount`. The load starts as soon as you navigate, so the page appears immediately instead of mounting empty and then filling itself in. There are two flavours, and one exception. | Page type | Pattern | Example to copy | | --------------------------------------- | ---------------------------------------------------------------- | ---------------------------------------------- | | Lists, tables, dashboards, detail views | `+page.ts`, **streamed** (return the promise, do not `await` it) | `routes/(protected)/notes/+page.ts` | | Editable forms | `+page.ts`, **awaited** (return the finished data) | `routes/(protected)/settings/+page.ts` | | Polling, WebSockets, upload progress | `onMount` in the component, with cleanup | `routes/(protected)/admin/health/+page.svelte` | **Streamed** means the load returns a promise it never awaited: ```ts export const load: PageLoad = async ({ depends }) => { depends(KEYS.notes); return { notes: listNotes() }; // note: no await }; ``` The page unwraps it with `{#await}`, which puts the loading, loaded and failed states in one place. You do not write a `loading` flag: ```text {#await data.notes} <NotesSkeleton /> {:then notes} <NotesGrid {notes} /> {:catch} <p>Failed to load notes.</p> {/await} ``` **Awaited** is for forms, and the reason is dirty-detection. When the load hands over finished data, `data` *is* the saved state, so "does this form have unsaved changes?" is just a comparison against it: ```ts let theme = $state(untrack(() => data.theme)); // seeded once, then follows the user const hasChanges = $derived(theme !== data.theme); ``` Do it the other way and you end up maintaining a second `originalTheme` copy by hand, and resyncing it after every save. **Two rules that keep this working:** 1. **Never copy load data into `$state`** on a read-only page. The copy goes stale the moment the load re-runs. Read `data` directly. Forms are the exception, and only for the fields being edited, as above. 1. **Refresh by re-running the load, not by refetching.** After a mutation, call `invalidate()` with the scope the load claimed via `depends()`: ```ts await deleteNote(id); await invalidate(KEYS.notes); // re-runs the load, which refetches the list ``` Every scope in the app is named in `lib/invalidation-keys.ts`. They live in one file because a mistyped scope fails silently: `invalidate('app:note')` matches nothing, refreshes nothing, and leaves stale data on screen without an error. Why `depends()` is needed at all SvelteKit can track fetches for you, but only when they go through the `fetch` it passes into the load function. Our Orval client uses its own, so each load names its scope explicitly with `depends()` and mutations invalidate it by that name. **Why not `onMount`?** It only runs after the component mounts, so navigation completes, the page renders empty, and *then* the request starts. You also hand-roll `loading` and `error` flags, re-fetching when a route parameter changes, and cancelling requests that a newer one has superseded. Load functions do all of that for you. Keep `onMount` for what is genuinely tied to the component's lifetime: a timer, a socket, an event listener you have to remove. `admin/health` polls on an interval, so it is the one page that owns its data in local state, and it is commented to say so. Background reading: [when to use load functions and onMount](https://turtledev.io/blog/sveltekit-spa-when-to-use-load-functions-and-onmount) and [load functions vs onMount](https://turtledev.io/blog/sveltekit-spa-load-functions-vs-onmount). ______________________________________________________________________ ## 6. Authentication & Security ### Session-based authentication We use session cookies (not JWT tokens): 1. User logs in → Backend creates session in database 1. Backend sends HTTP-only cookie with session ID 1. Every API call includes this cookie automatically 1. Backend checks: "Is this session valid?" before responding **Why session cookies instead of JWT?** - HTTP-only cookies can't be stolen by JavaScript (XSS protection) - Server controls sessions = instant logout - Simpler frontend code = no token refresh logic - Built-in CSRF protection with SameSite cookies Sessions expire after 24 hours (configurable). The backend stores hashed session tokens and compares them on each request. ### Role-based access control Four roles, ordered by precedence (`readonly` < `member` < `org_admin` < `sys_admin`): - **readonly** - View-only access - **member** - Basic user (can use the app) - **org_admin** - Manage organization (invite users, change settings) - **sys_admin** - Full system access (manage all orgs, see analytics) See [Authentication](https://docs.fastsvelte.dev/features/authentication/index.md) and [Security](https://docs.fastsvelte.dev/features/security/index.md) for the full model. Protect routes with role checks: ```python @router.get("/admin/users") async def list_users( current_user: CurrentUser = Depends(min_role_required(Role.SYSTEM_ADMIN)) ): # Only sys_admins can reach this ``` Routes in `(protected)/` automatically check authentication on the frontend: ```html <!-- (protected)/+layout.svelte --> <script> import { onMount } from "svelte"; import { ensureAuthenticated } from "$lib/auth/session"; onMount(async () => { await ensureAuthenticated(); // Redirects to login if not authenticated }); </script> ``` ______________________________________________________________________ ## 7. Design Decisions ### Why file name suffixes like `user_service.py`? Files are named `service/user_service.py` instead of `service/user.py`. The suffix appears redundant since the folder already indicates the layer, but it solves a practical problem. **Without suffixes:** ```text user.py | user.py | user.py | user.py ``` **With suffixes:** ```text user_route.py | user_service.py | user_model.py | user_repo.py ``` When multiple files are open, IDE tabs show filenames, not full paths. Without suffixes, every tab displays `user.py` - making navigation difficult. The suffix also improves search: typing "user_service" immediately finds the right file instead of filtering through four different `user.py` files across different folders. ### Where should imports go? Put imports at the top of the file by default. Python caches imported modules, so top-level imports cost nothing at runtime, and keeping them together makes a file's dependencies obvious at a glance. Move an import inside a function only for a specific reason: - **Optional dependencies**: a feature relying on a package not every install includes. An inline import keeps the module importable when the package is absent, failing only if the feature is actually used. The email provider factory does this: it imports the Azure, SendGrid, or Resend client only when that provider is selected, so you don't need all three SDKs installed. - **Breaking a circular import**: when two modules need each other at import time, a deferred import inside the function that needs it sidesteps the cycle. - **Smoke test isolation**: keeping a heavy or environment-dependent import out of module load so a smoke test can exercise the rest of the module without it. ### Why `Factory` vs `Singleton` in dependency injection? **Singleton** = one instance for the whole app: ```python # Same UserService instance every time user_service = providers.Singleton(UserService, user_repo=user_repo) ``` **Factory** = a fresh instance every time it's injected: ```python # Fresh instance per injection report_builder = providers.Factory(ReportBuilder, plan_repo=plan_repo) ``` FastSvelte's container registers everything as a `Singleton`, deliberately. Repos and services are stateless: they hold only references to other singletons and read-only config, so there is no per-request state to isolate. The one stateful component, the database connection pool, lives inside the `db_config` singleton precisely so that every repo shares one pool instead of each opening its own connections. The OpenAI client is similar: it keeps persistent HTTP connections open, and sharing one instance reuses them instead of reconnecting on every request. Use `Factory` when you add a component that holds per-request state (a unit-of-work object, a mutable builder, anything unsafe to share across concurrent requests). When you add an ordinary repo or service, register it as a `Singleton` like everything else in the container. ### How does error handling work? FastSvelte uses domain exceptions that inherit from `BaseAppException`. Each exception knows its own HTTP status code, error code, and message format. A global error handler middleware automatically converts these to JSON responses. **Services throw domain exceptions:** ```python from app.exception.common_exception import ResourceNotFound async def get_user(self, user_id: int): user = await self.user_repo.get_by_id(user_id) if not user: raise ResourceNotFound(resource="user", resource_id=user_id) return user ``` **Routes don't need try/catch - exceptions bubble up to middleware:** ```python @router.get("/{user_id}") async def get_user_route(user_id: int, user_service: UserService = Depends()): # No try/catch needed - middleware handles it return await user_service.get_user(user_id) ``` **Middleware automatically converts to HTTP response:** The global error handler in `app/api/middleware/error_handler.py` catches all `BaseAppException` instances and returns structured JSON responses with the appropriate status code, error code, message, and details. See [Section 3](#3-backend-layered-architecture) for why services shouldn't know about HTTP - they might be called from routes, background jobs, CLI scripts, or tests. ______________________________________________________________________ ______________________________________________________________________ **Next Steps:** - [Development Guide](https://docs.fastsvelte.dev/guides/development-workflow/index.md) - Start building - [B2B Mode](https://docs.fastsvelte.dev/features/multi-tenancy/index.md) - Configure team collaboration features - [Integrations](https://docs.fastsvelte.dev/features/authentication/index.md) - Add Stripe, SendGrid, and OAuth - [Troubleshooting](https://docs.fastsvelte.dev/reference/troubleshooting/index.md) - Fix issues # Troubleshooting Common issues and solutions for [FastSvelte](https://fastsvelte.dev) development and deployment. ## Setup Issues **Init script prerequisites not met** ```bash ✗ Python 3.12 not found ``` **Solution:** Install all required tools: - **Python 3.12+**: https://www.python.org/downloads/ - **Docker**: https://docs.docker.com/get-docker/ - **Node.js 22+**: https://nodejs.org/ **Init script permission denied** ```bash bash: ./init.py: Permission denied ``` **Solution:** Make the script executable: ```bash chmod +x init.py chmod +x backend/db/sqitch.sh ``` **Port conflicts during init** ```bash Error: Port 5432 already in use ``` **Solution:** Stop services using required ports (5432, 8000, 5173, 5174): ```bash # Find what's using the port lsof -i :5432 # Stop the service or change port in docker-compose.yml ``` **Docker daemon not running** ```bash ERROR: Cannot connect to Docker daemon ``` **Solution:** Start Docker: ```bash # macOS/Windows: Start Docker Desktop # Linux: sudo systemctl start docker ``` **pip install fails with dependency conflicts** ```bash ERROR: Cannot install fastapi>=0.104.0 and pydantic<2.0.0 ``` **Solution:** Update Python to 3.12+ and use clean virtual environment: ```bash python3.12 -m venv .venv source .venv/bin/activate pip install --upgrade pip pip install -r requirements.txt ``` **PostgreSQL connection refused** ```bash asyncpg.exceptions.ConnectionRefusedError: Connection refused ``` **Solution:** Ensure PostgreSQL is running: ```bash # Using Docker docker compose up db -d # Check if running docker ps | grep postgres # Verify connection psql postgres://postgres:postgres@localhost/fastsvelte -c "SELECT 1;" ``` **Database does not exist** ```bash asyncpg.exceptions.InvalidCatalogNameError: database "fastsvelte" does not exist ``` **Solution:** Create the database: ```bash # Connect to PostgreSQL and create database psql postgres://postgres:postgres@localhost -c "CREATE DATABASE fastsvelte;" # Or use Docker docker exec -it fastsvelte-db psql -U postgres -c "CREATE DATABASE fastsvelte;" ``` ______________________________________________________________________ ## Development Issues **API client generation fails** ```bash Error: Could not fetch OpenAPI spec from http://localhost:8000/openapi.json ``` **Solution:** Ensure backend is running before generating client: ```bash # Start backend first cd backend && uvicorn app.main:app --reload # Then generate in another terminal cd frontend && npm run generate ``` **Import errors with absolute imports** ```bash ModuleNotFoundError: No module named 'app.service' ``` **Solution:** Always use absolute imports from `app` package: ```python # Correct from app.service.user_service import UserService # Incorrect from ..service.user_service import UserService ``` **Dependency injection not working** ```bash TypeError: 'NoneType' object is not callable ``` **Solution:** Ensure your module is added to wiring configuration: ```python # app/config/container.py wiring_config = containers.WiringConfiguration( modules=[ "app.api.route.your_new_route", # Add this line # ... existing modules ] ) ``` **Hot reload not working** ```bash Changes not reflected in browser ``` **Solution:** Check file watchers and ports: ```bash # Backend uv run uvicorn app.main:app --reload # Frontend npm run dev -- --host 0.0.0.0 --port 5173 ``` ______________________________________________________________________ ## Database Issues **Migration fails with "relation already exists"** ```bash psycopg2.errors.DuplicateTable: relation "user" already exists ``` **Solution:** Use `IF NOT EXISTS` in migrations: ```sql CREATE TABLE IF NOT EXISTS fastsvelte."user" (...); ``` **Sqitch deploy fails with permission denied** ```bash bash: ./sqitch.sh: Permission denied ``` **Solution:** Make script executable: ```bash chmod +x backend/db/sqitch.sh ``` **Connection pool exhausted** ```bash asyncpg.exceptions.TooManyConnectionsError: too many connections ``` **Solution:** Adjust connection pool settings: ```python # app/data/db_config.py self.pool = await asyncpg.create_pool( self.dsn, min_size=5, # Reduce if needed max_size=10, # Reduce if needed command_timeout=60 ) ``` **Docker volumes not mounting** ```bash Database data lost after container restart ``` **Solution:** Create external volume: ```bash docker volume create fastsvelte-data docker compose up db -d ``` ______________________________________________________________________ ## Frontend Issues **CORS errors in development** ```bash Access to fetch blocked by CORS policy ``` **Solution:** Check CORS configuration in backend: ```python # app/config/settings.py @property def cors_origins(self) -> list[str]: return { "dev": ["http://localhost:5173", "http://localhost:4173"], # ... other environments }.get(self.environment, []) ``` **Authentication not persisting** ```bash User logged out after page refresh ``` **Solution:** Ensure cookies are configured properly: ```javascript // src/lib/api/axios.js export const axiosInstance = Axios.create({ baseURL: PUBLIC_API_BASE_URL, withCredentials: true, // This is crucial }); ``` **Svelte components not updating** ```bash Component state not reactive ``` **Solution:** Use Svelte 5 runes correctly: ```typescript // Correct let count = $state(0); // Incorrect let count = 0; ``` ______________________________________________________________________ ## Production Issues **Static assets not loading** ```bash 404 Not Found for /assets/app.js ``` **Solution:** Check build configuration and base path: ```javascript // frontend/vite.config.js export default { build: { outDir: "build", assetsDir: "assets", }, }; ``` **Database connection timeouts in production** ```bash asyncpg.exceptions.ServerTimeoutError: timeout ``` **Solution:** Increase connection timeout and pool settings: ```bash # Environment variables FS_DB_URL="postgres://user:pass@host/db?connect_timeout=60" ``` **High memory usage** ```bash Container killed: Out of memory ``` **Solution:** Optimize container resources and add memory limits: ```dockerfile # Dockerfile FROM python:3.12-slim # Use slim image # Add memory-efficient settings ENV PYTHONUNBUFFERED=1 ENV PYTHONDONTWRITEBYTECODE=1 ``` **SSL certificate errors** ```bash SSL_CERT_VERIFY_FAILED ``` **Solution:** Ensure proper SSL configuration: ```bash # Check certificate openssl s_client -connect yourdomain.com:443 # Verify DNS nslookup yourdomain.com ``` ______________________________________________________________________ ## Performance Issues **Slow database queries** ```bash Query execution time > 1000ms ``` **Solution:** Add indexes and optimize queries: ```sql -- Find slow queries SELECT query, mean_exec_time, calls FROM pg_stat_statements WHERE mean_exec_time > 100 ORDER BY mean_exec_time DESC; -- Add missing indexes CREATE INDEX CONCURRENTLY idx_user_organization_active ON fastsvelte."user"(organization_id) WHERE is_active = true; ``` **Memory usage constantly increasing** ```bash Memory usage: 85%+ ``` **Solution:** Check for memory leaks: ```python # Add connection cleanup async def cleanup_connections(): await db_config.pool.close() # Monitor connection pools async def get_pool_status(): return { "size": db_config.pool.get_size(), "active": db_config.pool.get_active_count(), "idle": db_config.pool.get_idle_count() } ``` ______________________________________________________________________ ## Security Issues **Users accessing protected routes without login** ```bash Unauthorized access to /admin ``` **Solution:** Verify route protection: ```python @router.get("/admin/users") async def list_users( current_user: CurrentUser = Depends(min_role_required(Role.SYSTEM_ADMIN)) ): # Ensure dependency is applied ``` **Sessions being stolen or reused** **Solution:** Implement proper session security: ```python # Rotate session tokens on login async def login(email: str, password: str): # ... authenticate user # Generate new session token new_token = secrets.token_urlsafe(32) # Invalidate old sessions for this user await session_repo.delete_by_user_id(user.id) # Create new session await session_repo.create(user.id, hash_token(new_token)) ``` ______________________________________________________________________ ## Monitoring & Debugging **View backend logs:** ```bash # Docker logs docker logs fastsvelte-api --follow # Local development tail -f backend/app.log ``` **View frontend logs:** - Open browser DevTools → Console - Check Network tab for API errors - Monitor Application tab for localStorage/cookies **Check database connections:** ```sql SELECT count(*) as active_connections FROM pg_stat_activity WHERE state = 'active'; ``` **API health check:** ```bash curl https://yourdomain.com/health ``` ______________________________________________________________________ ## Frequently Asked Questions **Why doesn't FastSvelte use `__init__.py` files?** FastSvelte is an **application, not a library**. While `__init__.py` has benefits for library code, they don't apply here: **Benefits of `__init__.py` (that don't apply to FastSvelte):** - **Shorter imports** (`from app.service import X`) - We prefer explicit paths that show exactly where code lives - **Public API facade** - Applications don't need API contracts; our layers (controller/service/repo) are already the boundaries - **Hiding internal structure** - Not needed when the team owns the full codebase - **Relative imports** - We deliberately use absolute imports (`from app.service.x`) everywhere, avoiding relative imports entirely **Problems avoided by not using `__init__.py`:** - **No circular import traps** - Re-exports often create hidden import cycles - **No hidden side effects** - Importing a package won't unexpectedly initialize clients or load config - **Simpler refactoring** - Moving files is mechanical; no export lists to maintain - **Explicit dependencies** - `from app.service.rule_service import RuleService` shows the exact file and ownership - **Less decision fatigue** - No need to decide what to export from each package **Note:** Some tools like `fastapi dev` expect `__init__.py` for package detection. Use `uvicorn app.main:app --reload` instead, which works without them and is what production uses anyway. ______________________________________________________________________ ## Getting Help When reporting issues, include: 1. **Environment details** - Development/production, OS, versions 1. **Error messages** - Complete error traces and logs 1. **Configuration** - Relevant environment variables (sanitized) 1. **Steps to reproduce** - Minimal reproduction steps 1. **Expected behavior** - What should happen vs what actually happens **Resources:** - [Architecture Overview](https://docs.fastsvelte.dev/reference/architecture/index.md) - Understanding the system - [Development Guide](https://docs.fastsvelte.dev/guides/development-workflow/index.md) - Development workflows - [Integrations](https://docs.fastsvelte.dev/features/authentication/index.md) - External service configuration