Skip to content

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:

  1. Session — reads vectis_session cookie, loads from Redis
  2. Cart session — generates vectis_cart_session UUID if missing
  3. Locale — resolves from cookie → Accept-Language"en"
  4. Security headersX-Frame-Options, X-Content-Type-Options, etc.
  5. Login wall — enforces full_wall / prices_hidden / public modes
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:8000 from browser JavaScript
  • Always define GraphQL ops in $lib/houdini/<PageName>.ts and use the generated stores from $houdini
  • All GraphQL (SSR or browser) must go through /api/graphql
  • After any change to $lib/houdini/*.ts, run npx houdini generate so types regenerate (included in npm run check)
  • The only exception is SearchAutocomplete.svelte querying Typesense with a scoped key (Decided #105)