Skip to content

Architecture ​

Request Lifecycle ​

Browser → Nuxt 4 (SSR)
  │
  ├── Middleware (auth-hydrate.global → named guards)
  ├── Layout   (wraps every page)
  └── Page     (composes components, loads TanStack queries)
       │
       └── Composable (api/)
            └── $api plugin (Bearer + CSRF + workspace_id injection)
                 └── Laravel backend REST API

For search: pages call useTypesenseSearch composables which POST to Nitro proxy routes at /api/typesense/*. The Nitro handlers sign requests server-side to avoid exposing the Typesense API key in the browser.

Data Layer Architecture ​

Two-Layer Composable Pattern ​

Every API surface has two layers:

LayerDirectoryRole
HTTP composablecomposables/api/useXyzApi.tsWraps $api calls, owns the request shape
Query/mutation composablecomposables/queries/useXyzQueries.tsWraps TanStack useQuery / useMutation, owns caching keys

Components always call the queries layer, never the API layer directly. This keeps raw HTTP calls testable in isolation.

TanStack Vue Query ​

  • Query keys live in constants/queryKeys.ts — always use them, never inline strings
  • useQuery for reads; useMutation for writes with onSuccess cache invalidation
  • staleTime defaults: assets/folders = 30 s; workspace settings = 5 min; user profile = 5 min
  • On mutation success, invalidate the affected query key immediately via queryClient.invalidateQueries

$api Plugin ​

plugins/fetch.ts configures $fetch as $api with:

  • Bearer token from useAuthToken() composable
  • X-XSRF-TOKEN header from cookie (CSRF)
  • workspace_id injected into every request
  • AbortController integration via cancelTokenManager util
  • Error normalization — throws a typed ApiError on non-2xx

Authentication Architecture ​

Auth state lives in three places and must stay in sync:

StoreValue
SSR cookie (collage_token)HTTP-only, read by Nitro during SSR
localStorageClient-only persistence
useState('auth')Nuxt shared state, source of truth for components

middleware/auth-hydrate.global.ts runs on every navigation, reads the cookie/localStorage, validates the JWT (expiry check), and populates useState('auth'). Named middleware then gate specific routes.

Passkey auth uses @simplewebauthn/browser and posts to the Laravel backend; after success the response contains the same JWT token and follows the standard auth flow.

State Management (Pinia) ​

Four stores — all Options Store syntax:

StorePurpose
dam.tsDAM tree state: selected folder, asset selection, view mode
dialog.tsGlobal dialog queue: which dialog is open, its props
loading.tsNamed loading flags for page-level skeletons
analytics.tsAmplitude-enrichment context (workspace, user properties)

Rule: Components read from stores via getters; they mutate state only by calling actions. Never store.field = value from a component.

Component Architecture ​

Components live in app/components/ with subdirectory namespacing. Nuxt auto-imports them with pathPrefix: false, so a file at components/asset/AssetCard.vue is registered as <AssetCard>, not <AssetAssetCard>.

Groupings:

DirectoryContents
global/App shell, nav drawer, breadcrumbs, notifications
asset/Asset grid, card, detail panel, uploader, version history
collage/Collage grid, card, detail panel
dam/Folder tree, DAM toolbar, bulk actions
dam/Dialogs/Move/copy/share/delete dialogs for DAM entities
dialogs/General-purpose dialogs (confirm, input, crop)
search/Search bar, filter panels, result lists
shared/Generic reusable UI (buttons, chips, empty states)

Server (Nitro) ​

Nitro routes proxy three surfaces that must not expose credentials to the client:

PathPurpose
server/api/typesense/*Typesense search — signs requests with server-side API key
server/api/s3/*S3 multipart upload — returns presigned URLs
server/api/csrf.get.tsIssues CSRF cookie
server/api/image-proxy.get.tsProxies external image URLs to avoid CORS
server/routes/health.get.tsHealth check endpoint

Rate limiting (server/utils/rateLimit.ts) applies to Typesense and S3 routes.

Analytics Architecture ​

See Amplitude Analytics for full event reference.

Amplitude is initialized in plugins/amplitude.client.ts. Context enrichment (workspace, user properties) is performed via a custom plugin that reads from analytics.ts store. Components call tracking composables — they never call amplitude.track() directly.

Real-time (Pusher/Echo) ​

plugins/echo.client.ts initializes Laravel Echo with Pusher transport. Components subscribe to channels in onMounted and unsubscribe in onUnmounted — never at the page level to avoid memory leaks. Channel naming: workspace.{id}, asset.{id}, folder.{id}.