Technische Stack
Overzicht
Section titled “Overzicht”| App | Stack | Domein |
|---|---|---|
| Dashboard | Vite 6 + React 19 + Zustand 5 | app.builtwithbeam.com |
| API | Hono 4.7 op CF Workers | api.builtwithbeam.com |
| Public Site | Astro 5.8 SSR op CF Pages | Klant-domeinen |
| Shared | TypeScript types + utilities | Interne library |
Monorepo met pnpm workspaces. 100% Cloudflare hosting.
Dashboard
Section titled “Dashboard”- 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-sidebarattribuut direct actief voor CSS regels - EditorPage / PatternEditorPage:
preloadBlockImages()gestart tijdens render, parallel met lazy chunk
Block background flash prevention:
- Fallback
background: #ffffffvervangen 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-* headersStructured 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-Idheader: 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 generatieBindings (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_IDPublic Site
Section titled “Public Site”- 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=600KV 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.
| Key | Inhoud | TTL |
|---|---|---|
host:{hostname} | Site context (siteId, ownerId, siteSlug) | 24h |
site:{siteId} | Site config + menus + nav + logo | 24h |
page:{siteId}:{slug} | Page data (blocks, SEO, settings) | 24h |
brand-css:{siteId} | Pre-generated CSS string | 24h |
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.astroSecurity headers (SSR middleware + _headers file voor static assets):
| Header | Waarde |
|---|---|
Content-Security-Policy | default-src 'self'; scripts/styles 'unsafe-inline' (Astro view transitions); images beperkt tot eigen domein + media CDN + Supabase; frame-ancestors 'none' |
Strict-Transport-Security | max-age=63072000; includeSubDomains; preload (2 jaar) |
X-Content-Type-Options | nosniff |
X-Frame-Options | DENY (legacy clickjacking protectie) |
Referrer-Policy | strict-origin-when-cross-origin |
Permissions-Policy | camera=(), microphone=(), geolocation=(), payment=() |
Cross-Origin-Opener-Policy | same-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-Preloadinternal 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-lazyclass) - Above-fold detectie via
data-above-fold(eager loading,fetchpriority="high", sync decoding) Vary: Hostheader voor correcte CDN caching per domein
Shared Package
Section titled “Shared Package”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 preventieimage-optimization.ts— SRCSET_WIDTHS, URL generators, preload helpersmood-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)State Management
Section titled “State Management”Zustand Editor Store:
// Gecreeerd met createWithEqualityFn + immer middlewareuseEditorStore: { // 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 supportuseSites() → GET alle sites van de owner (actief, deleted_at IS NULL)useSite() → Afgeleide hook: useSites() + localStorage selectie → huidige siteuseCurrentSiteId() → Leest geselecteerde siteId uit localStorageswitchSite(id) → Zet siteId in localStorage, revalidate alle hooksusePages() → GET pages voor huidige siteusePatterns() → GET patternsuseMedia() → GET media met filtering/sortinguseMenus() → GET menus voor huidige siteuseDomains() → GET domains via API voor huidige siteuseTeam() → GET team members + invitationsuseProfile() → GET user profileuseSettings() → GET site settings voor huidige siteAlle 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.
Realtime Sync
Section titled “Realtime Sync”Cross-tab sync (BroadcastChannel API):
Tab A: delete media → broadcastMediaDeleted(id) → BroadcastChannelTab 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 eventUser B: usePatternRealtimeSync() ← luistert → replacePatternBlocks()Twee hooks:
useMediaRealtimeSync()— Luistert naarDELETEopmediatabel. Vervangt verwijderde media achtergronden met witte kleur +_deleted_mediaflag.usePatternRealtimeSync()— Luistert naarUPDATEoppatternstabel. 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.
Repository & Git
Section titled “Repository & Git”- Conventionele commits:
feat:,fix:,refactor:,chore:,docs: - Kleine, gefocuste commits
- Quality gates bij elke significante wijziging:
pnpm typecheck— 0 errorspnpm lint— 0 errors, 0 warningspnpm build— voor geraakt(e) app(s)pnpm test:e2e— 21 Playwright tests- Security review
Cloudflare Services
Section titled “Cloudflare Services”| Service | Gebruik |
|---|---|
| Pages | Dashboard hosting, public site hosting |
| Workers | API hosting, cron triggers |
| R2 | Media opslag (originals + thumbnails) |
| Image Resizing | Blur placeholders, thumbnails, responsive images |
| Custom Domains API | Klant-domeinen koppelen aan public site |
| KV | ISR cache voor publieke site (BEAM_CACHE namespace, gedeeld API + Pages) |
| Cache API | Pagina cache + purge bij publicatie |
Supabase
Section titled “Supabase”| Feature | Gebruik |
|---|---|
| Auth | PKCE flow, magic links, JWT tokens |
| Database | PostgreSQL met 50+ migraties, RLS op alle tabellen |
| Realtime | Live sync voor patterns en media (BroadcastChannel + Supabase) |
| Storage | Avatars, 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)
CI/CD & GitHub Actions
Section titled “CI/CD & GitHub Actions”| Workflow | Trigger | Stappen |
|---|---|---|
deploy-api.yml | Push naar main (apps/api/**) | Typecheck -> Wrangler deploy -> Email notificatie |
deploy-dashboard.yml | Push naar main (apps/dashboard/, packages/shared/) | Build -> CF Pages deploy -> Sentry source maps -> Email |
deploy-public-site.yml | Push naar main (apps/public-site/**) | Astro build -> CF Pages deploy |
e2e.yml | Na dashboard deploy / handmatig | 21 Playwright tests (auth, pages, editor, media, smoke) |
Deploy notificaties via Resend bij success/failure.
Deployment targets:
| App | Platform | Project | Domein |
|---|---|---|---|
| Dashboard | CF Pages | beam-dashboard | app.builtwithbeam.com |
| API | CF Workers | beam-api | api.builtwithbeam.com |
| Public Site | CF Pages | beam-public-site | klant-domeinen + *.pages.dev |
| Router Worker | CF Workers | beam-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.
Environment Variables
Section titled “Environment Variables”Dashboard (Vite, build-time):
| Variabele | Doel |
|---|---|
VITE_SUPABASE_URL | Supabase endpoint |
VITE_SUPABASE_ANON_KEY | Public Supabase key |
VITE_API_URL | API endpoint |
VITE_SENTRY_DSN | Sentry ingestion URL |
API (Wrangler vars + secrets):
| Variabele | Type | Doel |
|---|---|---|
SUPABASE_URL | Var | Supabase endpoint |
SUPABASE_ANON_KEY | Secret | Public key |
SUPABASE_SERVICE_ROLE_KEY | Secret | Admin key (RLS bypass) |
CF_ACCOUNT_ID | Var | Cloudflare account |
CF_ZONE_ID | Var | Cloudflare zone |
CF_API_TOKEN | Secret | Cloudflare API token |
DASHBOARD_ORIGIN | Var | CORS origin |
RESEND_API_KEY | Secret | Email API |
UNSPLASH_ACCESS_KEY | Secret | Stock photos |
PEXELS_API_KEY | Secret | Stock photos |
SENTRY_DSN | Secret | Error tracking |
MEDIA_BUCKET | R2 binding | Media opslag |
BEAM_CACHE | KV binding | ISR cache (gedeeld met public site) |
Public Site (Astro):
| Variabele | Doel |
|---|---|
PUBLIC_SUPABASE_URL | Supabase endpoint |
PUBLIC_SUPABASE_ANON_KEY | Public key (read-only) |
BEAM_CACHE | KV binding |
Performance
Section titled “Performance”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,useCallbackop 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-Preloadinternal 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-lazyclass - Above-fold detectie:
data-above-foldattribuut voorfetchpriority="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
Security
Section titled “Security”XSS Preventie:
isomorphic-dompurifyvia@beam/shared/sanitizein alle block renderers- CSP headers op publieke site (SSR middleware +
_headersfile 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) +
_headersfile (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.messagenaar 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()enget_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()(nietgetSession()) - Email verificatie bij team uitnodigingen
- Magic links voor nieuwe gebruikers