Files
tibi-svelte-starter/.agents/skills/frontend-architecture/SKILL.md
T
apairon 491f495c66 feat: enhance project setup and architecture documentation
- Updated `tibi-project-setup` skill to clarify project initialization goals and steps.
- Improved `tibi-ssr-caching` skill to detail SSR architecture, responsibilities, and caching mechanisms.
- Introduced `website-solution-architecture` skill for translating website requirements into coherent solutions.
- Refined `AGENTS.md` to provide a structured roadmap for project development phases.
- Added `ADMIN_ASSET_VERSION` to `api/config.yml.env` for asset versioning.
- Updated SSR request flow and cache invalidation logic in `api/hooks/ssr/AGENTS.md`.
- Removed obsolete `esbuild.config.admin.js` and integrated asset versioning into the main `esbuild.config.js`.
- Adjusted `api/collections/content.yml` to utilize asset versioning for admin scripts.
2026-05-12 20:01:22 +00:00

15 KiB

name, description
name description
frontend-architecture 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:

// 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

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" } })

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

<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:

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

// 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

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

Reactive state

<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

<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:

<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

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 })

Error handling

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)
}

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 }

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:
    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

<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 may have both id and _id.
  • $location strips trailing slashes/about/ becomes /about (except root /).
  • Content cache is 1 hourgetCachedEntries 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.