BFF Pattern¶
Vectis uses a Backend-for-Frontend architecture. The browser never calls the FastAPI GraphQL backend directly — all requests route through SvelteKit's server layer.
Why BFF?¶
- Security — auth tokens and session secrets stay server-side (httpOnly cookies)
- Performance — SSR pages resolve data server-side, reducing client round-trips
- Simplicity — single origin, no CORS configuration needed
Architecture¶
Browser ──▶ SvelteKit (BFF) ──▶ FastAPI /graphql
• /api/graphql (Houdini client target — SSR + browser)
Both admin and storefront use Houdini (houdini + houdini-svelte). Houdini's generated client points at the BFF route /api/graphql, so a single forwarder serves both SSR loaders (load_<QueryName> in +page.ts) and browser mutations (generated <Op>Store classes).
Data Loading (SSR)¶
Pages use +page.ts with a load_<QueryName> helper from $houdini; the GraphQL op lives in $lib/houdini/<PageName>.ts:
// src/routes/products/+page.ts
import { load_ProductList } from '$houdini';
export const load = async (event) => {
const main = await load_ProductList({ event, variables: { limit: 20 } });
return { ...main };
};
No hand-written gql() helper — Houdini handles fetch, cache, and typing.
BFF Forwarder (/api/graphql)¶
Every request — SSR or browser — hits /api/graphql in SvelteKit first. The route reads cookies and attaches headers before forwarding to FastAPI:
| Header | Source | Purpose |
|---|---|---|
Authorization | vectis_session → Redis (JWT, refreshed ~12 min threshold) | Authenticated API calls |
X-Session-ID | vectis_cart_session cookie | Guest cart identification |
X-Locale | vectis_locale cookie | Content language resolution |
X-Currency | vectis_currency cookie | Price currency selection |
The JWT never reaches the browser (Decided #88). In the storefront, the header-building lives in src/lib/server/api.ts as apiHeaders(cookies); in admin, the /api/graphql/+server.ts handler reads the session directly.
Client-Side Mutations¶
Houdini generates a <MutationName>Store class for each mutation; instantiate and call .mutate():
import { UpdateProductStore } from '$houdini';
const updateProduct = new UpdateProductStore();
await updateProduct.mutate({ id, input: { name } });
The request goes through /api/graphql just like SSR — the browser never learns the JWT.
hooks.server.ts¶
The server hook runs on every request and handles:
- Session — reads
vectis_sessioncookie, loads from Redis - Cart session — generates
vectis_cart_sessionUUID if missing - Locale — resolves from cookie →
Accept-Language→"en" - Security headers —
X-Frame-Options,X-Content-Type-Options, etc. - Login wall — enforces
full_wall/prices_hidden/publicmodes
export const handle: Handle = async ({ event, resolve }) => {
event.locals.session = await getSession(event.cookies.get('vectis_session'));
event.locals.locale = resolveLocale(event);
if (!event.cookies.get('vectis_cart_session')) {
event.cookies.set('vectis_cart_session', crypto.randomUUID(), {
path: '/', maxAge: 30 * 24 * 60 * 60, httpOnly: true, sameSite: 'lax'
});
}
return resolve(event);
};
Tip
When debugging API issues, check hooks.server.ts first — a missing X-Locale usually means the vectis_locale cookie isn't set.
Exception: Instant Search Autocomplete (Decided #105)¶
The one sanctioned bypass of the BFF rule is storefront search autocomplete. The browser queries Typesense directly using a scoped API key that limits access to the public storefront collection with read-only search permissions.
Browser ──▶ Typesense (scoped key) ← only /documents/search
↕ no auth tokens exposed
SvelteKit (BFF) fetches the scoped key via storefrontSearchConfig GraphQL query
The scoped key is fetched server-side in +layout.server.ts and passed to the SearchAutocomplete component as data.searchConfig. If the key is unavailable or PUBLIC_TYPESENSE_URL is unset, autocomplete is disabled and search falls back to SSR form submission.
Warning
Do not extend this exception to other APIs. Search autocomplete is safe because scoped keys restrict the query to a single read-only collection with visibility filters baked in.
Rules¶
- Never import
$lib/server/*in client-side code - Never call
http://api:8000from browser JavaScript - Always define GraphQL ops in
$lib/houdini/<PageName>.tsand use the generated stores from$houdini - All GraphQL (SSR or browser) must go through
/api/graphql - After any change to
$lib/houdini/*.ts, runnpx houdini generateso types regenerate (included innpm run check) - The only exception is
SearchAutocomplete.sveltequerying Typesense with a scoped key (Decided #105)