# 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
goto(resolve('/projects/new'))}>
New project
{#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}
handleDelete(project.id)}>
Delete
{/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
```
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:
- This project:
## Done when
-
## Manual check
-
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
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
```
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 ` 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
```
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 ` ` (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 `` 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 ``: 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 `` per route, passing that page's title and description:
```text
```
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 `` and default social title. |
| `description` | yes | ` ` 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 ` ` or canonical in `+layout.svelte` so every page inherits it. **Don't.** SvelteKit deduplicates ``, but it does **not** dedupe ` ` or ` ` tags; they accumulate. Two things go wrong:
- **Duplicate descriptions.** Any page that sets its *own* description on top of the layout default ships *two* ` ` 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
```
## Verify
After `npm run build` (and `npm run preview`), inspect the homepage source. You should see exactly **one** ` ` and **one** self-referencing ` `:
```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 `