---
name: frontend-architecture
description: Understand the frontend architecture — custom SPA routing, state management, Svelte 5 patterns, API layer, error handling, and i18n. Use when working on routing logic, navigation, stores, or understanding how the frontend fits together.
---

# frontend-architecture

## When to use this skill

Use this skill when:

- Understanding or modifying the SPA routing mechanism
- Working with stores or state management
- Debugging navigation issues
- Adding new Svelte 5 reactive patterns
- Understanding the API layer and error handling
- Working with i18n / multi-language features
- Understanding how SSR and SPA loading share one app-level data path

---

## Routing: custom SPA router

This project uses a **custom SPA router** — NOT SvelteKit, NOT file-based routing. Pages are CMS-managed content entries loaded by path.

### Architecture

```
Browser URL change
    ↓
history.pushState / replaceState (proxied in store.ts)
    ↓
$location store updates (path, search, hash)
    ↓
App.svelte $effect reacts to $location.path
    ↓
loadContent(lang, routePath) → API call: getCachedEntries("content", { lang, path, active: true })
    ↓
ContentEntry.blocks[] → BlockRenderer.svelte → individual block components
```

### Key files

| File                                       | Responsibility                                                                                                                                      |
| ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| `frontend/src/lib/store.ts`                | Proxies `history.pushState`/`replaceState` → updates `$location` writable store. Handles `popstate` for back/forward.                               |
| `frontend/src/lib/navigation.ts`           | `spaNavigate(url, options)` — the programmatic navigation API. Also: `initScrollRestoration()`, `spaLink` action, hash parsing.                     |
| `frontend/src/lib/i18n.ts`                 | Language routing: `extractLanguageFromPath()`, `stripLanguageFromPath()`, `localizedPath()`, `currentLanguage` derived store, `ROUTE_TRANSLATIONS`. |
| `frontend/src/App.svelte`                  | Reacts to `$location.path` + `$currentLanguage`, loads content via API, passes blocks to `BlockRenderer`.                                           |
| `frontend/src/blocks/BlockRenderer.svelte` | Maps `block.type` to Svelte components.                                                                                                             |

### How the location store works

`store.ts` wraps `history.pushState` and `history.replaceState` with a `Proxy`:

```typescript
// Simplified — see store.ts for full implementation
history.pushState = new Proxy(history.pushState, {
    apply: (target, thisArg, args) => {
        // Update $location store BEFORE the actual pushState
        publishLocation(args[2]) // args[2] = URL
        Reflect.apply(target, thisArg, args)
    },
})
```

This means **any** `pushState`/`replaceState` call (from `spaNavigate`, `<a>` clicks, or third-party code) automatically updates `$location`.

The `popstate` event (back/forward buttons) also triggers `publishLocation()`.

### URL structure

```
/{lang}/{path}
  ↓       ↓
  de    /ueber-uns

Example: /de/ueber-uns → lang="de", routePath="/ueber-uns"
         /en/about     → lang="en", routePath="/about"
         /de/          → lang="de", routePath="/"
```

Root `/` redirects to `/{browserLanguage}/` via `getBrowserLanguage()`.

### SSR interaction with routing

This frontend is not just an SPA. The same top-level app also participates in SSR.

- `frontend/src/ssr.ts` is intentionally thin and should mostly bootstrap locale state and call `render(App, { props: { url } })`.
- `App.svelte` owns page loading for both browser and SSR.
- Browser navigation triggers page loading from `$effect`.
- SSR triggers the same page-loading path directly inside `typeof window === "undefined"`.

This means route changes, i18n path handling, and content-loading behavior must be reasoned about together. If a route works in the browser but SSR returns empty content or 404, inspect the mapping between:

- public URL (`/de/...`)
- stripped route path (`/...`)
- `content.path` in the DB
- `api/hooks/config.js` SSR route validation

### Navigation API

```typescript
import { spaNavigate } from "./lib/navigation"

// Basic navigation (creates history entry, scrolls to top)
spaNavigate("/de/kontakt")

// Replace current entry (no back button)
spaNavigate("/de/suche", { replace: true })

// Keep scroll position
spaNavigate("/de/produkte#filter=shoes", { noScroll: true })

// With state object
spaNavigate("/de/produkt/123", { state: { from: "search" } })
```

### SPA link action

For `<a>` elements, use the `spaLink` action instead of `spaNavigate`:

```svelte
<script>
    import { spaLink } from "../lib/navigation"
</script>

<a href="/de/kontakt" use:spaLink>Kontakt</a>
<a href="/de/suche" use:spaLink={{ replace: true }}>Suche</a>
```

The action intercepts clicks (respecting modifier keys, external links, `target="_blank"`) and calls `spaNavigate` internally.

### BrowserSync SPA fallback

In development, BrowserSync uses `connect-history-api-fallback` to serve `index.html` for all routes, enabling client-side routing. In production, the webserver or tibi-server handles this.

### Localized route translations

For translated URL slugs (e.g. `/ueber-uns` ↔ `/about`), configure `ROUTE_TRANSLATIONS` in `frontend/src/lib/i18n.ts`:

```typescript
export const ROUTE_TRANSLATIONS: Record<string, Record<SupportedLanguage, string>> = {
    about: { de: "ueber-uns", en: "about" },
    contact: { de: "kontakt", en: "contact" },
    // Add more as needed
}
```

Keep in mind that these translations affect the public URL shape and therefore also the SSR route-validation layer. Changing localized slugs is not purely a frontend concern.

---

## State management

The project uses **Svelte writable/derived stores** (not a centralized state library).

### Store inventory

| Store                  | File                   | Purpose                                                                          |
| ---------------------- | ---------------------- | -------------------------------------------------------------------------------- |
| `location`             | `lib/store.ts`         | Current URL state (path, search, hash, push/pop flags)                           |
| `mobileMenuOpen`       | `lib/store.ts`         | Whether mobile hamburger menu is open                                            |
| `currentContentEntry`  | `lib/store.ts`         | Currently displayed page entry data such as `translationKey`, `lang`, and `path` |
| `previousPath`         | `lib/store.ts`         | Previous URL path (for conditional back buttons)                                 |
| `apiBaseOverride`      | `lib/store.ts`         | Override API base URL (used by admin module)                                     |
| `cookieConsentVisible` | `lib/store.ts`         | Whether cookie consent banner is showing                                         |
| `currentLanguage`      | `lib/i18n.ts`          | Derived from `$location.path` — current language code                            |
| `selectedLanguage`     | `lib/i18n.ts`          | Writable — synced with `currentLanguage` on navigation                           |
| `activeRequests`       | `lib/requestsStore.ts` | Number of in-flight API requests (drives `LoadingBar`)                           |

### Pattern: creating a new store

```typescript
// In lib/store.ts or a dedicated file
import { writable, derived } from "svelte/store"

// Simple writable
export const myStore = writable<MyType>(initialValue)

// Derived from other stores
export const myDerived = derived(location, ($loc) => {
    return computeFromPath($loc.path)
})
```

---

## Svelte 5 patterns used in this project

This project uses **Svelte 5 with Runes**. Key patterns:

### Component props

```svelte
<script lang="ts">
    // Rune syntax — replaces export let
    let { block, className = "" }: { block: ContentBlockEntry; className?: string } = $props()
</script>
```

### Reactive state

```svelte
<script lang="ts">
    // Local reactive state (replaces let x; with $: reactivity)
    let count = $state(0)
    let items = $state<Item[]>([])

    // Computed/derived values (replaces $: derived = ...)
    let total = $derived(items.reduce((sum, i) => sum + i.price, 0))

    // Side effects (replaces $: { ... } reactive blocks)
    $effect(() => {
        // Runs when dependencies change
        console.log("count changed:", count)
    })
</script>
```

### SSR-safe code

```svelte
<script lang="ts">
    import { untrack } from "svelte"

    // Guard browser-only APIs
    if (typeof window !== "undefined") {
        window.addEventListener("scroll", handleScroll, { passive: true })
    }

    // untrack: capture initial value without creating reactive dependency
    // Used in App.svelte for SSR initial URL
    untrack(() => {
        if (url) { /* set initial location */ }
    })
</script>
```

### Svelte stores in Svelte 5

Stores (`writable`, `derived`) still work in Svelte 5. Use `$storeName` syntax in components:

```svelte
<script lang="ts">
    import { location } from "./lib/store"
    // $location is reactive — auto-subscribes in Svelte 5
</script>
<p>Current path: {$location.path}</p>
```

---

## API layer

### Core function: `api()`

Located in `frontend/src/lib/api.ts`. Features:

- **Request deduplication** — identical concurrent GETs share one promise
- **Loading indicator** — drives `activeRequests` store → `LoadingBar`
- **Build-version check** — auto-reloads page when server build is newer
- **Mock interceptor** — when `__MOCK__` is `true`, routes requests to `frontend/mocking/*.json`
- **Sentry integration** — span instrumentation (when enabled)

### Shared browser/SSR transport

The project intentionally shares the low-level API transport between browser and SSR via `api/hooks/lib/ssr`.

- In the browser, it eventually becomes `fetch(...)`.
- In SSR, `apiRequest(...)` delegates to `context.ssrRequest(...)`.
- GET responses reached during SSR are written into `window.__SSR_CACHE__` for hydration.

This is why SSR can preload both content and navigation without building a separate frontend-only data layer.

### Usage patterns

```typescript
import { api, getCachedEntries, getCachedEntry, getDBEntries, postDBEntry } from "./lib/api"

// Cached (1h TTL, for read-heavy data)
const pages = await getCachedEntries<"content">("content", { lang: "de", active: true })
const page = await getCachedEntry<"content">("content", { path: "/about" })

// Uncached
const items = await getDBEntries<"content">("content", { type: "blog" }, "sort", 10)

// Write
const result = await postDBEntry("content", { name: "New Page", active: true })

// Raw API call
const { data, count } = await api<MyType[]>("mycollection", { filter: { active: true }, limit: 20 })
```

### `aggregate` for sub-queries

The server supports an `aggregate` parameter to compute reverse aggregates against another collection and store the result under `_aggregate`. This efficiently calculates counts, sums, existence, etc. without embedding the target documents.

```typescript
const res = await api<MyEntry[]>("mycollection", {
    filter: { active: true },
    params: {
        // String syntax: "collection:foreignField:op:valueField:as"
        aggregate: "posts:categoryId:count",
        // JSON syntax for advanced use cases (custom source field, filtering)
        aggregate: JSON.stringify({
            collection: "comments",
            foreignField: "entryId",
            op: "count",
            filter: { approved: true },
            as: "approvedComments",
        }),
    },
})
// Result in res.data[0]._aggregate.postsCount  and res.data[0]._aggregate.approvedComments
```

Available operations: `count` (default), `exists`, `sum`, `avg`, `min`, `max`.

### Error handling

```typescript
try {
    const result = await api<ContentEntry[]>("content", { filter: { path: "/missing" } })
} catch (err) {
    // err has shape: { response: Response, data: { error: string } }
    const status = (err as any)?.response?.status // e.g. 404
    const message = (err as any)?.data?.error // e.g. "Not found"

    // For user-visible errors:
    import { addToast } from "./lib/toast"
    addToast({ type: "error", message: "Seite nicht gefunden" })

    // For debugging:
    console.error("[MyComponent] API error:", err)
}
```

### `aggregate` for sub-queries

The server supports an `aggregate` parameter to compute reverse aggregates against another collection and store the result under `_aggregate`. This efficiently calculates counts, sums, existence, etc. without embedding the target documents.

```typescript
const res = await api<MyEntry[]>("mycollection", {
    filter: { active: true },
    params: {
        // String syntax: "collection:foreignField:op:valueField:as"
        aggregate: "posts:categoryId:count",
        // JSON syntax for advanced use cases (custom source field, filtering)
        aggregate: JSON.stringify({
            collection: "comments",
            foreignField: "entryId",
            op: "count",
            filter: { approved: true },
            as: "approvedComments",
        }),
    },
})
// Result in res.data[0]._aggregate.postsCount  and res.data[0]._aggregate.approvedComments
```

Available operations: `count` (default), `exists`, `sum`, `avg`, `min`, `max`.

### Error handling guidelines

| Scenario                          | Approach                                          |
| --------------------------------- | ------------------------------------------------- |
| API error the user should see     | `addToast({ type: "error", message })`            |
| API error that's silently handled | `console.error(...)` for dev logging              |
| Unexpected error in production    | Sentry captures automatically (when enabled)      |
| Missing content / 404             | Set `notFound = true` → renders `NotFound.svelte` |
| Network error / offline           | Loading bar stays visible; user can retry         |

### API request flow (client-side)

```
Component calls api() / getCachedEntries()
    ↓
Deduplication check (skip if signal provided)
    ↓
incrementRequests() → LoadingBar appears
    ↓
__MOCK__? → mockApiRequest() (in-memory JSON filtering)
    ↓ (else)
apiRequest() from api/hooks/lib/ssr (shared with SSR bundle)
    ↓
fetch("${apiBaseURL}${endpoint}?filter=...&sort=...&limit=...")
    ↓
Parse response → check X-Build-Time header
    ↓
decrementRequests() → LoadingBar disappears
    ↓
Return { data, count, buildTime }
```

### `_count` endpoint

Der tibi-server stellt einen dedizierten `_count`-Endpoint bereit, der **nur** `{"count": N}` zurückgibt – kein Data-Transfer:

```
GET /api/{collection}/_count?filter={"active":true,"category":"<id>"}
→ {"count": 8}
```

Der Endpoint wird durch den BrowserSync-Proxy korrekt geroutet (`/api` → `/api/v1/_/{namespace}`).

**Frontend-Aufruf:**

```ts
const res = await api<{ count: number }>("machines/_count", {
    filter: { active: true, category: catId },
})
// res.data.count === 8
```

Das ist effizienter als `count=1&limit=1`, weil keine Collection-Objekte serialisiert/übertragen werden.

### `select` für schlanke Queries

Der tibi-server unterstützt einen `select`-Parameter als Komma-Liste der gewünschten Felder. Nicht gelistete Felder werden nicht übertragen:

```ts
const res = await api<MachineEntry[]>("machines", {
    filter: { active: true, category: catId },
    sort: "sortOrder",
    limit: 20,
    params: {
        lookup: "images:medialib",
        select: "name,slug,tagline,priceFrom,weight,sku,images",
    },
})
```

Nicht aufgeführte Felder (z.B. `description`, `specs`) entfallen – spart Bandbreite bei Listen/Grids. `_lookup` und `id` werden automatisch ergänzt.

**Wichtig:** `select` muss als String im `params`-Objekt übergeben werden (der `apiRequest` hängt es als Query-Parameter an). Es wird direkt an den tibi-server durchgereicht.

---

## i18n system

### Architecture

- **svelte-i18n** for translation strings (`$_("key")`)
- **URL-based language routing** (`/{lang}/...`)
- **Lazy-loaded locale files** in `frontend/src/lib/i18n/locales/{lang}.json`
- **Route translations** for localized URL slugs

### Adding a new language

1. Create locale file: `frontend/src/lib/i18n/locales/fr.json`
2. Add to `SUPPORTED_LANGUAGES` in `frontend/src/lib/i18n.ts`:
    ```typescript
    export const SUPPORTED_LANGUAGES = ["de", "en", "fr"] as const
    ```
3. Add label: `export const LANGUAGE_LABELS = { ..., fr: "Français" }`
4. Add route translations for the new language in `ROUTE_TRANSLATIONS`.
5. Register in `frontend/src/lib/i18n/index.ts` (lazy loader).
6. Create content entries with `lang: "fr"` in the CMS.

### Translation usage

```svelte
<script>
    import { _ } from "./lib/i18n/index"
</script>

<h1>{$_("hero.title")}</h1>
<p>{$_("hero.subtitle", { values: { name: "World" } })}</p>
```

---

## Common pitfalls

- **Never `spaNavigate()` in SSR** — always guard with `typeof window !== "undefined"`.
- **Store subscriptions in modules** — if subscribing to stores outside components, remember to unsubscribe to prevent memory leaks.
- **API PUT returns only changed fields** — don't expect a full object back from PUT requests.
- **`_id` not `id` for filters** — API filters use MongoDB's `_id`, but response objects only have `id` as string via API.
- **`$location` strips trailing slashes** — `/about/` becomes `/about` (except root `/`).
- **Content cache is 1 hour** — `getCachedEntries` caches in memory for 1h. For admin previews, use `getDBEntries` (uncached).
- **`$effect` alone is not SSR** — server-side rendering must trigger the same data path explicitly outside browser-only reactive effects.
- **A rendered shell is not enough** — always verify that SSR HTML actually contains page-critical content and navigation.

## Cart persistence (localStorage)

For SSR-safe cart/inquiry persistence:

```ts
let cartItems = $state<any[]>([])
// Laden
$effect(() => {
    try {
        if (typeof localStorage !== "undefined") {
            const saved = localStorage.getItem("cart_key")
            if (saved) cartItems = JSON.parse(saved)
        }
    } catch {}
})
// Speichern
$effect(() => {
    try {
        if (typeof localStorage !== "undefined") {
            localStorage.setItem("cart_key", JSON.stringify(cartItems))
        }
    } catch {}
})
```

Immer mit `typeof localStorage !== "undefined"` für SSR-Sicherheit.
