Skip to content

Technische Stack

AppStackDomein
DashboardVite 6 + React 19 + Zustand 5app.builtwithbeam.com
APIHono 4.7 op CF Workersapi.builtwithbeam.com
Public SiteAstro 5.8 SSR op CF PagesKlant-domeinen
SharedTypeScript types + utilitiesInterne library

Monorepo met pnpm workspaces. 100% Cloudflare hosting.

  • Framework: Vite 6 + React 19
  • Routing: React Router v7 — 3 layout-groepen, 26 lazy-loaded pagina’s
  • State: Zustand 5 + Immer (editor store), SWR (server data, dedupingInterval: 30s)
  • UI: Tailwind 3.4 + cn() helper, @dnd-kit (drag & drop), Motion (animaties)
  • Forms: React Hook Form + Zod
  • API: Typed Hono client (api-client.ts)

Directory structuur:

src/
├── components/
│ ├── blocks/editor/ # 8 editor block componenten
│ ├── blocks/public/ # 8 public block componenten (preview in editor)
│ ├── dashboard/ # Sidebar, site-switcher, invite-modal, domain-settings
│ ├── color-picker/ # Gradient/kleur UI
│ ├── image-editor/ # Cropper + focal point picker
│ ├── header-preview.tsx # React replica van SiteHeader.astro voor live preview
│ └── ui/ # Avatar, Modal, ContextMenu, Skeleton, BrowserMockup, ConfirmModal
├── layouts/ # Auth, Dashboard, Editor layouts
├── lib/
│ ├── api-client.ts # Typed wrapper voor Hono API
│ ├── auth-context.tsx # Supabase auth provider (React Context)
│ ├── store.ts # Zustand editor store (blocks, history, selection)
│ ├── types.ts # Block & editor types (re-exports @beam/shared)
│ ├── blocks.ts # Block registry met iconen & defaults
│ ├── supabase/ # Service layer (12 modules)
│ ├── font-loader.ts # Self-hosted brand font loading (variable + static woff2, FontFace API)
│ ├── hooks/ # 15 SWR hooks + realtime hooks + use-lazy-font-loading
│ ├── sanitize.ts # XSS preventie (isomorphic-dompurify)
│ └── utils.ts, constants.ts, fields.ts, ...
└── pages/ # 26 lazy-loaded page componenten (incl. SettingsSeoPage, SettingsDomainsPage)

Route tree (React Router v7 createBrowserRouter):

RootLayout (AuthProvider + NavigationProvider)
├── AuthLayout (geen sidebar)
│ ├── /login
│ ├── /register
│ ├── /forgot-password
│ └── /reset-password
├── DashboardLayout (sidebar + topbar)
│ ├── /dashboard
│ ├── /pages
│ ├── /media, /media/:id
│ ├── /menus, /menus/header, /menus/footer, /menus/:id
│ ├── /patterns
│ ├── /settings # Mijn site (naam, homepage, afbeeldingen, gevarenzone)
│ ├── /settings/seo # SEO (metadata, titel, taal, social preview)
│ ├── /settings/domains # Domeinnamen (subdomein + custom domains)
│ ├── /settings/users # Gebruikers (team management, site-scoped)
│ └── /account
├── EditorLayout (fullscreen, geen sidebar)
│ ├── /editor/new, /editor/:id
│ └── /patterns/new, /patterns/:id
├── /invite/accept (standalone)
└── /auth/callback (standalone)

Image cache (lib/image-cache.ts):

Gedeelde module-level cache (max 500 entries) voor alle image componenten (BackgroundImagePublic, BackgroundImageEditor, BlurImage, ColumnImage). URLs worden genormaliseerd (query params gestript) zodat dezelfde fysieke afbeelding op verschillende formaten herkend wordt. Eenmaal geladen → blur-up animatie overgeslagen, image direct zichtbaar. preloadBlockImages() wordt aangeroepen op page card hover (PagesPage), EditorPage mount én PatternEditorPage mount — parallel met lazy chunk load.

Anti-flash pattern (synchroon initialiseren tijdens render):

Voor elke side effect die UI beïnvloedt vóór de eerste paint: niet in useEffect (te laat, children renderen al met stale state) maar synchroon tijdens render via useState lazy init + change detection. Toegepast op:

  • PageEditor / PatternEditor: useEditorStore.getState().setPage(initialPage) — store gevuld vóór Canvas rendert
  • DashboardLayout: applyBrandCssVars() — brand CSS vars gezet vóór children paint
  • useBrandRealtimeSync: brand vars + fonts synchroon in editor
  • DashboardSidebar: data-sidebar attribuut direct actief voor CSS regels
  • EditorPage / PatternEditorPage: preloadBlockImages() gestart tijdens render, parallel met lazy chunk

Block background flash prevention:

  • Fallback background: #ffffff vervangen door transparant (geen witte flash)
  • Background CSS transitions (background 0.3s ease-out) uitgeschakeld eerste 500ms na mount

Navigatie:

Directe navigatie via React Router startTransition. Geen sidebar exit-animatie delay, geen toolbar slide-out wachttijd. Canvas entrance: Motion spring scale (0.96 → 1, 350ms, bounce 0.15). Grid items: Motion stagger (0.03s per card, spring 0.3s).

Chunk splitting (Vite manualChunks):

{
'vendor-react': ['react', 'react-dom', 'react-router'],
'vendor-supabase': ['@supabase/supabase-js'],
'vendor-ui': ['lucide-react', 'motion'],
'vendor-dnd': ['@dnd-kit/core', '@dnd-kit/sortable', '@dnd-kit/utilities'],
'vendor-state': ['zustand', 'swr', 'immer'],
'vendor-form': ['react-hook-form', 'zod'],
'vendor-editor': ['@tiptap/*'] // Lazy-loaded via React.lazy()
}
  • Framework: Hono 4.7 op Cloudflare Workers
  • Middleware stack: CORS -> Auth (Bearer JWT) -> Rate Limit -> Logger (JSON structured) -> Sentry
  • Routes: 10 modules — pages, patterns, media, team, domains, sites, stock-photos, cache, health, ai
  • Validatie: Zod schemas op alle endpoints
  • Crons: Domain status polling (*/5 min), media cleanup (03:00 UTC), expired sites hard delete (03:00 UTC dagelijks)
  • Logging: JSON structured met request ID, user ID, method, path, status, duration

Middleware stack flow:

Client Request
→ Status subdomain interceptor (status.builtwithbeam.com → statuspagina, skip rest)
→ Sentry middleware (vangt alle errors)
→ Structured logger (request ID, method, path, duration, status, userId)
→ CORS middleware (DASHBOARD_ORIGIN only, localhost alleen in dev)
→ /health, /health/services (geen auth, rate limit 30 req/min per IP)
→ Auth middleware (Bearer token → supabase.auth.getUser())
→ Rate limiter (100 req/min per user, 20 req/min uploads, 10 req/min AI)
→ Route handler
→ Supabase queries (admin of user client)
→ JSON response + X-Request-Id + X-RateLimit-* headers

Structured logging formaat:

{"level":"info","requestId":"abc123","method":"POST","path":"/media/upload","status":200,"duration":142,"userId":"user-id"}
  • Log levels: info (success), warn (4xx), error (5xx of exceptions)
  • Alleen slow requests (>1s) en errors worden gelogd in productie
  • X-Request-Id header: unieke request ID voor tracing (doorgestuurd naar client)

Rate limiting response headers:

X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset. Bij overschrijding: HTTP 429 met Retry-After. State is per-isolate (niet globaal gedeeld) — voldoende voor abuse preventie.

Volledige routes:

/health GET Health check (geen auth)
/health/services GET Service status JSON (geen auth, 30s cache)
/status/services GET Service status detail (auth, responseMs + errors)
/domains/* POST connect, disconnect, set-primary, status
/patterns/* POST propagate · GET usage
/pages/:id PUT save · POST cascade-hrefs
/media/* POST upload, bulk-delete, video/upload (TUS presign), external, cleanup
PATCH update, video/:id/status (Bunny encoding poll)
PUT :id/image (replace)
DELETE :id, :id/references
GET check-duplicate
/media/:id/tags POST add · DELETE remove
/media/bulk/tags POST add · DELETE remove
/team/* POST invite, accept, decline, leave
PATCH members/:id/role
DELETE invitations/:id, members/:id
GET invitations/info
/stock-photos/* GET search (Unsplash/Pexels) · POST download tracking
/sites/:id DELETE Soft delete site (set deleted_at)
/cache/purge POST Cloudflare CDN cache invalidatie
/cache/invalidate POST KV cache invalidatie per site (dashboard calls)
/brand/:siteId GET Brand profile (auto-create) · PUT update design tokens
/brand/:siteId/versions GET Token versie geschiedenis · POST named snapshot
/brand/:siteId/versions/:id/restore POST Herstel token snapshot
/brand/:siteId/tokens/css GET Public CSS custom properties (geen auth, incl. semantic + shades + dark mode)
/brand/:siteId/tokens GET W3C Design Tokens JSON export (geen auth)
/brand/:siteId/tokens/tailwind GET Tailwind v4 @theme CSS export (geen auth)
/brand/:siteId/contrast GET WCAG AA contrast check rapport (geen auth)
/mood-presets GET Sfeer-presets lijst (public, geen auth)
/ai/generate-hero POST AI hero content generatie (3 varianten, Anthropic tool_use)
/ai/generate-block POST AI per-block content generatie (smart merge, block context, verfijn/vervang)

Directory structuur:

src/
├── index.ts # App setup: Sentry → logger → CORS → health → auth → rate limit → routes → crons
├── types.ts # Env (bindings + secrets) + Variables (userId, supabase clients)
├── middleware/
│ ├── auth.ts # Bearer token → supabase.auth.getUser() → userId in context
│ ├── cors.ts # DASHBOARD_ORIGIN only (localhost alleen in dev)
│ ├── logger.ts # Structured JSON logging met request ID en duration
│ ├── rate-limit.ts # Sliding window rate limiter (general + upload + AI)
│ └── sentry.ts # toucan-js Sentry integratie
├── routes/ # 10 route modules (domains, patterns, pages, media, team, sites, stock-photos, cache, status, ai)
│ └── ai.ts # AI content generatie (Anthropic Claude, tool_use, brand context)
├── scheduled/ # 3 cron handlers (domain-status, media-cleanup, hard-delete-sites)
└── lib/ # Supabase clients, CF API helpers, domain validatie, blur generatie

Bindings (wrangler.toml):

[vars]
SUPABASE_URL, CF_ACCOUNT_ID, CF_ZONE_ID, DASHBOARD_ORIGIN, APP_URL
[[r2_buckets]]
binding = "MEDIA_BUCKET"
[[kv_namespaces]]
binding = "BEAM_CACHE" # Gedeeld met public site (ISR cache)
# Secrets (via wrangler secret put):
# SUPABASE_ANON_KEY, SUPABASE_SERVICE_ROLE_KEY
# CF_API_TOKEN, RESEND_API_KEY, RESEND_FROM_EMAIL
# UNSPLASH_ACCESS_KEY, PEXELS_API_KEY
# BUNNY_API_KEY, BUNNY_LIBRARY_ID
  • Framework: Astro 5.8 met @astrojs/cloudflare adapter
  • Rendering: SSR op CF Pages
  • Routing: Dynamic catch-all [...slug].astro
  • Blocks: 8 Astro server-rendered components (~4KB JS totaal)
  • Performance: CF Image Resizing, LQIP blur, responsive srcSet
  • Cache: KV edge cache (~1ms) + CDN (s-maxage=300, stale-while-revalidate=600)
  • Security: XSS sanitisatie via isomorphic-dompurify

Request flow (met KV ISR cache):

Bezoeker → Cloudflare CDN (edge cache)
→ CF Pages Worker
→ Host-based middleware (KV host lookup ~1ms, fallback Supabase)
→ [...slug].astro (dynamic catch-all route)
→ KV cache check: site config (~1ms) + page data (~1ms) + brand CSS (~1ms)
→ hit: render direct (totaal ~3-5ms)
→ miss: Supabase fallback → render → populate KV (write-on-miss)
→ Astro SSR → HTML response
→ Cache headers: s-maxage=300, stale-while-revalidate=600

KV cache (ISR — Incremental Static Regeneration):

Cloudflare KV als edge-cache laag. Data wordt instant geïnvalideerd bij publicatie (niet op timer). Zie ADR-008 voor architectuurbeslissing.

KeyInhoudTTL
host:{hostname}Site context (siteId, ownerId, siteSlug)24h
site:{siteId}Site config + menus + nav + logo24h
page:{siteId}:{slug}Page data (blocks, SEO, settings)24h
brand-css:{siteId}Pre-generated CSS string24h

Bestanden: apps/public-site/src/lib/kv-cache.ts (reads), apps/api/src/lib/kv-cache.ts (invalidatie).

Block componenten:

src/components/blocks/
├── BlockRenderer.astro # Dispatcht naar juiste block type
├── BlocksRenderer.astro # Rendert array van blocks
├── ColumnsBlock.astro
├── CTABlock.astro
├── FAQBlock.astro
├── FeaturesBlock.astro
├── GroupBlock.astro
├── HeroBlock.astro
└── TextBlock.astro

Security headers (SSR middleware + _headers file voor static assets):

HeaderWaarde
Content-Security-Policydefault-src 'self'; scripts/styles 'unsafe-inline' (Astro view transitions); images beperkt tot eigen domein + media CDN + Supabase; frame-ancestors 'none'
Strict-Transport-Securitymax-age=63072000; includeSubDomains; preload (2 jaar)
X-Content-Type-Optionsnosniff
X-Frame-OptionsDENY (legacy clickjacking protectie)
Referrer-Policystrict-origin-when-cross-origin
Permissions-Policycamera=(), microphone=(), geolocation=(), payment=()
Cross-Origin-Opener-Policysame-origin

Performance features:

  • ~4KB JavaScript totaal (Astro islands, geen client-side framework)
  • Inline CSS (~18KB, 5.5KB gzip) — elimineert render-blocking stylesheet
  • 103 Early Hints voor font preloading + LCP hero image (via X-LCP-Preload internal header)
  • <link rel="preload"> voor hero images met srcSet/sizes
  • Speculation Rules (eagerness: 'moderate') voor prefetch on hover
  • View Transitions (@view-transition { navigation: auto })
  • Blur-to-real cross-fade op alle afbeeldingen (picture-lazy class)
  • Above-fold detectie via data-above-fold (eager loading, fetchpriority="high", sync decoding)
  • Vary: Host header voor correcte CDN caching per domein

packages/shared/src/ bevat:

  • types.ts — Single source of truth voor alle types (Block, Page, BackgroundConfig, BrandColorToken, BrandStory, parseBrandStory, etc.)
  • styles.ts — Button styling, spacing, block class utilities, getButtonInlineStyle(), resolveButtonStyle()
  • color-utils.ts — Kleur manipulatie, gradient parsing, resolveBackgroundColor(), isLightColor(), OKLCH conversie (hexToOklch(), oklchToHex()), shade scale generatie (generateShadeScale() met memoization), WCAG contrast checking (checkWcagContrast(), suggestAccessibleColor()), semantic token generatie (generateSemanticTokens(), generateDarkModeTokens())
  • font-manifest.ts — Self-hosted brand font manifest (19 variable + 3 static), generateFontFaceCss(), getFontPreloadPaths(), isBrandFont()
  • sanitize.ts — DOMPurify wrapper voor XSS preventie
  • image-optimization.ts — SRCSET_WIDTHS, URL generators, preload helpers
  • mood-presets.ts — 6 mood presets, 12 font combinaties, DesignTokens, MoodPreset, getMoodPreset(), getDefaultDesignTokens()

Exports map:

"." → types, utilities (clsx, tailwind-merge)
"./types" → Block, Page, BackgroundConfig, GradientConfig, SpacingValue, ...
"./styles" → Class generation utilities
"./color-utils" → Kleur manipulatie, gradient parsing, resolveBackgroundColor()
"./image-optimization" → Image URL transformatie (width, height, quality, blur)
"./sanitize" → isomorphic-dompurify wrapper (XSS preventie)
"./embed-utils" → Iframe embed parsing (YouTube, Vimeo)

Zustand Editor Store:

// Gecreeerd met createWithEqualityFn + immer middleware
useEditorStore: {
// Page data
page: Page // Huidige pagina met blocks
originalPage: Page // Snapshot bij laden (dirty check)
// Selection
selection: SelectionState // Geselecteerde block(s)
// History (undo/redo)
history: {
past: Page[] // Max 50 snapshots
future: Page[] // Redo stack
}
// Actions
updateBlock(blockId, updates)
addBlock(type, position)
removeBlock(blockId)
moveBlock(fromIndex, toIndex)
duplicateBlock(blockId)
insertBlocks(blocks, position) // Pattern insert
replacePatternBlocks(patternId, blocks, name)
markDeletedMedia(mediaId)
undo() / redo()
}

History debounce: Debounced met 500ms window. Eerste edit slaat pre-mutatie snapshot op als baseline. Snelle opeenvolgende edits verlengen de debounce zonder het baseline te overschrijven.

SWR Data Hooks:

// Pattern: hook + optimistic cache mutation
// Alle SWR keys zijn geparametriseerd op siteId voor multi-site support
useSites() → GET alle sites van de owner (actief, deleted_at IS NULL)
useSite() → Afgeleide hook: useSites() + localStorage selectie → huidige site
useCurrentSiteId() → Leest geselecteerde siteId uit localStorage
switchSite(id) → Zet siteId in localStorage, revalidate alle hooks
usePages() → GET pages voor huidige site
usePatterns() → GET patterns
useMedia() → GET media met filtering/sorting
useMenus() → GET menus voor huidige site
useDomains() → GET domains via API voor huidige site
useTeam() → GET team members + invitations
useProfile() → GET user profile
useSettings() → GET site settings voor huidige site

Alle hooks: dedupingInterval: 30_000, revalidateOnFocus: false.

Multi-site support: getCurrentSiteId() leest de geselecteerde site uit localStorage. Alle service layer functies (settings.ts, menus.ts, pages.ts, team.ts) vereisen een siteId parameter. Team management is site-scoped — de UsersPage toont het team van de geselecteerde site met een selectbox om te switchen. Gecentraliseerde getSessionUserId() in lib/auth.ts vervangt 3 duplicate getUserId helpers.

Cross-tab sync (BroadcastChannel API):

Tab A: delete media → broadcastMediaDeleted(id) → BroadcastChannel
Tab B: useMediaRealtimeSync() ← luistert → markDeletedMedia(id)

Instant, geen server roundtrip. Fallback op Supabase Realtime als BroadcastChannel niet beschikbaar is.

Cross-user sync (Supabase Realtime):

User A: save pattern → Supabase UPDATE → postgres_changes event
User B: usePatternRealtimeSync() ← luistert → replacePatternBlocks()

Twee hooks:

  • useMediaRealtimeSync() — Luistert naar DELETE op media tabel. Vervangt verwijderde media achtergronden met witte kleur + _deleted_media flag.
  • usePatternRealtimeSync() — Luistert naar UPDATE op patterns tabel. Vervangt pattern container children met nieuwe blocks_data.

Deduplicatie: Media realtime sync voorkomt dubbele verwerking via een processedRef Set met 10s cleanup timer (opgeslagen in Map voor cleanup bij unmount).

Video Encoding Poll:

useVideoEncodingPoll(mediaIds: string[])

Pollt Bunny Stream encoding status voor video’s die nog niet klaar zijn (bunny_status < 4). Gebruikt ref pattern voor stabiele callbacks, batch API endpoint en exponential backoff (2s → 30s). Bij status 4 (Finished) wordt de SWR media cache optimistic bijgewerkt. Zie Bunny Stream integratie voor details.

  • Conventionele commits: feat:, fix:, refactor:, chore:, docs:
  • Kleine, gefocuste commits
  • Quality gates bij elke significante wijziging:
    1. pnpm typecheck — 0 errors
    2. pnpm lint — 0 errors, 0 warnings
    3. pnpm build — voor geraakt(e) app(s)
    4. pnpm test:e2e — 21 Playwright tests
    5. Security review
ServiceGebruik
PagesDashboard hosting, public site hosting
WorkersAPI hosting, cron triggers
R2Media opslag (originals + thumbnails)
Image ResizingBlur placeholders, thumbnails, responsive images
Custom Domains APIKlant-domeinen koppelen aan public site
KVISR cache voor publieke site (BEAM_CACHE namespace, gedeeld API + Pages)
Cache APIPagina cache + purge bij publicatie
FeatureGebruik
AuthPKCE flow, magic links, JWT tokens
DatabasePostgreSQL met 50+ migraties, RLS op alle tabellen
RealtimeLive sync voor patterns en media (BroadcastChannel + Supabase)
StorageAvatars, legacy media, pattern previews

Clients:

  • Dashboard: anon key + user JWT (RLS enforced)
  • API Worker: service role key (admin ops, RLS bypass)
  • Public Site: anon key (read-only)
WorkflowTriggerStappen
deploy-api.ymlPush naar main (apps/api/**)Typecheck -> Wrangler deploy -> Email notificatie
deploy-dashboard.ymlPush naar main (apps/dashboard/, packages/shared/)Build -> CF Pages deploy -> Sentry source maps -> Email
deploy-public-site.ymlPush naar main (apps/public-site/**)Astro build -> CF Pages deploy
e2e.ymlNa dashboard deploy / handmatig21 Playwright tests (auth, pages, editor, media, smoke)

Deploy notificaties via Resend bij success/failure.

Deployment targets:

AppPlatformProjectDomein
DashboardCF Pagesbeam-dashboardapp.builtwithbeam.com
APICF Workersbeam-apiapi.builtwithbeam.com
Public SiteCF Pagesbeam-public-siteklant-domeinen + *.pages.dev
Router WorkerCF Workersbeam-router*.builtwithbeam.com (subdomain routing)

Router Worker (workers/router/):

Routeert alle *.builtwithbeam.com traffic:

  • Reserved subdomains (app, www) -> CF Pages dashboard (beam-dashboard.pages.dev)
  • Alle andere subdomains -> CF Pages public site (beam-public-site.pages.dev)

De worker set Host naar het origin en X-Beam-Host naar het oorspronkelijke hostname voor app-level routing. Origins geconfigureerd als DASHBOARD_ORIGIN en PAGES_ORIGIN in wrangler.toml vars.

Dashboard (Vite, build-time):

VariabeleDoel
VITE_SUPABASE_URLSupabase endpoint
VITE_SUPABASE_ANON_KEYPublic Supabase key
VITE_API_URLAPI endpoint
VITE_SENTRY_DSNSentry ingestion URL

API (Wrangler vars + secrets):

VariabeleTypeDoel
SUPABASE_URLVarSupabase endpoint
SUPABASE_ANON_KEYSecretPublic key
SUPABASE_SERVICE_ROLE_KEYSecretAdmin key (RLS bypass)
CF_ACCOUNT_IDVarCloudflare account
CF_ZONE_IDVarCloudflare zone
CF_API_TOKENSecretCloudflare API token
DASHBOARD_ORIGINVarCORS origin
RESEND_API_KEYSecretEmail API
UNSPLASH_ACCESS_KEYSecretStock photos
PEXELS_API_KEYSecretStock photos
SENTRY_DSNSecretError tracking
MEDIA_BUCKETR2 bindingMedia opslag
BEAM_CACHEKV bindingISR cache (gedeeld met public site)

Public Site (Astro):

VariabeleDoel
PUBLIC_SUPABASE_URLSupabase endpoint
PUBLIC_SUPABASE_ANON_KEYPublic key (read-only)
BEAM_CACHEKV binding

Dashboard:

  • Code splitting: Lazy-loaded routes + manual vendor chunks
  • SWR dedup: 30s interval, geen onnodige refetches
  • Skeleton loading: Cached counts in localStorage voor instant UI
  • Memoization: memo() op list items, useCallback op handlers
  • API timeout: 30s AbortController op alle API calls

Publieke Site:

  • ~4KB JS: Astro genereert minimaal JavaScript
  • Inline CSS: ~18KB (5.5KB gzip), geen render-blocking stylesheet
  • KV ISR cache: ~1-5ms reads (host, site config, page data, brand CSS)
  • Edge caching: s-maxage=300, stale-while-revalidate=600
  • 103 Early Hints: Font + LCP hero image preloading via X-LCP-Preload internal header
  • Hero preload: <link rel="preload"> met srcSet/sizes + 103 Early Hints
  • Speculation Rules: Prefetch on hover (eagerness: 'moderate')
  • View Transitions: Smooth page navigatie via CSS API
  • Image loading: Blur-to-real cross-fade via picture-lazy class
  • Above-fold detectie: data-above-fold attribuut voor fetchpriority="high", eager loading, sync decoding + preload
  • LCP: Base64 blur placeholder als CSS background + preload hint
  • CLS: Inline body { margin:0 } voorkomt Tailwind Preflight shift
  • Parallel queries: Promise.all([getSiteData(), getPageBySlug()])

API:

  • Workers runtime: V8 isolate, ~0ms cold start
  • R2 direct binding: Geen AWS SDK overhead
  • Query limits: .limit(200-500) op alle Supabase queries
  • Structured logging: Alleen slow/error requests gelogd in productie
  • Rate limiting: In-memory sliding window per user, geen externe dependencies

XSS Preventie:

  • isomorphic-dompurify via @beam/shared/sanitize in alle block renderers
  • CSP headers op publieke site (SSR middleware + _headers file voor static assets)

Publieke Site Hardening:

  • CSP: default-src 'self', images beperkt tot eigen domein + media CDN, frame-ancestors 'none'
  • HSTS: 2 jaar, includeSubDomains, preload-ready
  • X-Frame-Options: DENY (legacy clickjacking protectie)
  • X-Content-Type-Options: nosniff
  • Referrer-Policy: strict-origin-when-cross-origin
  • Permissions-Policy: camera, microphone, geolocation, payment uitgeschakeld
  • Cross-Origin-Opener-Policy: same-origin
  • Dubbele laag: Astro SSR middleware (dynamische responses) + _headers file (statische assets)

API Hardening:

  • Bearer token auth middleware op alle routes
  • CORS beperkt tot DASHBOARD_ORIGIN (localhost alleen in dev)
  • Rate limiting: 100 req/min per user, 20 req/min uploads, 30 req/min ongeauthenticeerd
  • Structured logging: JSON logs met request ID voor tracing en incident response
  • SSRF preventie: private IP range blocking op externe media URLs
  • Foutmeldingen: generieke tekst (geen error.message naar client)
  • Ownership verificatie via RLS queries voor pattern propagation
  • Slug validatie: regex max 63 tekens (page slug), parameter max 200 tekens (cascade-hrefs input)
  • Query limits op alle database queries

Database:

  • RLS op alle tabellen met (select auth.uid()) performance pattern
  • Site-scoped access via get_user_site_ids() en get_user_role(uid, site_id)
  • INSERT policies enforced site membership check
  • Service role client alleen server-side (API Worker)

Auth:

  • Supabase PKCE flow (geen implicit grant)
  • Token verificatie via supabase.auth.getUser() (niet getSession())
  • Email verificatie bij team uitnodigingen
  • Magic links voor nieuwe gebruikers