28 KiB
Multi-Frontend Architecture Plan
Overview
This document outlines the architecture for managing one shared backend with two separate frontends:
| Frontend | Domain | Purpose |
|---|---|---|
| Heritage Custodian Portal | bronhouder.nl |
Heritage custodian management, ontology visualization, SPARQL queries |
| ArchiefAssistent (AA) | archief.support |
National Archives services, archival research assistance |
Current State
Infrastructure
- Server: Hetzner Cloud CX22 (
91.98.224.44) - Web Server: Caddy (automatic HTTPS, reverse proxy)
- Deployment: Local SSH/rsync via
deploy.sh - Multi-domain: Already supported in Caddyfile
Backend Services (Shared)
| Service | Port | Purpose |
|---|---|---|
| Oxigraph | 7878 | SPARQL triplestore |
| FastAPI | 8000 | General API |
| PostgreSQL API | 8001 | Postgres queries |
| Geo API | 8002 | PostGIS geographic data |
| TypeDB API | 8003 | Graph database |
| DuckLake | 8765 | Time travel analytics |
| Qdrant | 6333 | Vector search |
| Valkey | 8090 | Semantic cache |
Current Frontend
- Framework: React 19 + TypeScript + Vite
- UI Library: MUI (Material-UI)
- State: React Context + TanStack Query
- Visualization: D3, MapLibre GL, Mermaid, Three.js
Proposed Architecture
Option 1: Monorepo with Shared Packages (RECOMMENDED)
glam/
├── packages/ # Shared packages
│ ├── ui/ # Shared UI components
│ │ ├── src/
│ │ │ ├── components/
│ │ │ │ ├── Button/
│ │ │ │ ├── Card/
│ │ │ │ ├── DataTable/
│ │ │ │ ├── ErrorBoundary/
│ │ │ │ ├── Layout/
│ │ │ │ ├── Navigation/
│ │ │ │ └── Tooltip/
│ │ │ └── index.ts
│ │ ├── package.json
│ │ └── tsconfig.json
│ │
│ ├── hooks/ # Shared React hooks
│ │ ├── src/
│ │ │ ├── useApi.ts
│ │ │ ├── useSparql.ts
│ │ │ ├── useGeocoding.ts
│ │ │ ├── useVectorSearch.ts
│ │ │ └── index.ts
│ │ ├── package.json
│ │ └── tsconfig.json
│ │
│ ├── api-client/ # Shared API client
│ │ ├── src/
│ │ │ ├── sparql.ts
│ │ │ ├── geo.ts
│ │ │ ├── rag.ts
│ │ │ ├── ducklake.ts
│ │ │ └── index.ts
│ │ ├── package.json
│ │ └── tsconfig.json
│ │
│ ├── visualizations/ # Shared visualization components
│ │ ├── src/
│ │ │ ├── Graph/
│ │ │ ├── Map/
│ │ │ ├── UML/
│ │ │ ├── RDF/
│ │ │ └── Timeline/
│ │ ├── package.json
│ │ └── tsconfig.json
│ │
│ └── theme/ # Shared theming (but customizable)
│ ├── src/
│ │ ├── base.ts # Base MUI theme
│ │ ├── bronhouder.ts # bronhouder.nl theme overrides
│ │ ├── archief.ts # archief.support theme overrides
│ │ └── index.ts
│ ├── package.json
│ └── tsconfig.json
│
├── apps/ # Individual frontends
│ ├── bronhouder/ # bronhouder.nl frontend
│ │ ├── src/
│ │ │ ├── pages/ # Site-specific pages
│ │ │ ├── components/ # Site-specific components
│ │ │ ├── contexts/ # Site-specific contexts
│ │ │ ├── App.tsx
│ │ │ └── main.tsx
│ │ ├── public/
│ │ ├── index.html
│ │ ├── vite.config.ts
│ │ ├── package.json
│ │ └── tsconfig.json
│ │
│ └── archief-assistent/ # archief.support frontend
│ ├── src/
│ │ ├── pages/ # AA-specific pages
│ │ ├── components/ # AA-specific components
│ │ ├── contexts/ # AA-specific contexts
│ │ ├── App.tsx
│ │ └── main.tsx
│ ├── public/
│ ├── index.html
│ ├── vite.config.ts
│ ├── package.json
│ └── tsconfig.json
│
├── backend/ # Unchanged - shared backend
└── infrastructure/ # Updated for multi-site
├── deploy.sh # Updated with --site flag
└── caddy/
└── Caddyfile # Multi-domain configuration
Why Monorepo with Shared Packages?
| Benefit | Description |
|---|---|
| Code Reuse | Share UI components, hooks, and API clients across both frontends |
| Consistency | Single source of truth for shared logic |
| Independent Deployment | Deploy frontends separately without affecting each other |
| Customization | Each frontend can override/extend shared components |
| Type Safety | Shared TypeScript types across packages |
| Testing | Test shared components once, use everywhere |
Implementation Plan
Phase 1: Monorepo Setup (Week 1)
1.1 Install Workspace Tool
We'll use pnpm workspaces (faster than npm/yarn, excellent monorepo support):
# Install pnpm globally
npm install -g pnpm
# Initialize workspace in project root
cd /Users/kempersc/apps/glam
Create pnpm-workspace.yaml:
packages:
- 'packages/*'
- 'apps/*'
Update root package.json:
{
"name": "glam-monorepo",
"private": true,
"scripts": {
"dev:bronhouder": "pnpm --filter @glam/bronhouder dev",
"dev:archief": "pnpm --filter @glam/archief-assistent dev",
"build:all": "pnpm -r build",
"build:bronhouder": "pnpm --filter @glam/bronhouder build",
"build:archief": "pnpm --filter @glam/archief-assistent build",
"test:all": "pnpm -r test",
"lint:all": "pnpm -r lint"
},
"devDependencies": {
"typescript": "~5.9.3"
}
}
1.2 Create Shared Packages
packages/ui/package.json:
{
"name": "@glam/ui",
"version": "0.1.0",
"private": true,
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./components/*": "./src/components/*/index.ts"
},
"peerDependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0",
"@mui/material": "^7.0.0",
"@emotion/react": "^11.0.0",
"@emotion/styled": "^11.0.0"
},
"devDependencies": {
"@types/react": "^19.2.5",
"typescript": "~5.9.3"
}
}
packages/hooks/package.json:
{
"name": "@glam/hooks",
"version": "0.1.0",
"private": true,
"main": "./src/index.ts",
"types": "./src/index.ts",
"peerDependencies": {
"react": "^19.0.0",
"@tanstack/react-query": "^5.0.0",
"axios": "^1.0.0"
}
}
packages/api-client/package.json:
{
"name": "@glam/api-client",
"version": "0.1.0",
"private": true,
"main": "./src/index.ts",
"types": "./src/index.ts",
"dependencies": {
"axios": "^1.13.2"
}
}
Phase 2: Extract Shared Components (Week 2)
2.1 Identify Shared vs. Site-Specific Components
Shared Components (move to packages/ui):
| Component | Current Location | Shared Because |
|---|---|---|
ErrorBoundary |
components/common/ |
Universal error handling |
LazyLoadError |
components/common/ |
Code splitting support |
Tooltip |
components/ui/ |
Generic UI primitive |
SearchableMultiSelect |
components/ui/ |
Generic form control |
Layout |
components/layout/ |
App shell (customizable) |
Navigation |
components/layout/ |
Navigation (customizable menu) |
DataTable |
(new) | Tabular data display |
Card |
(new) | Content container |
Modal |
(new) | Dialog/modal wrapper |
Shared Visualization Components (move to packages/visualizations):
| Component | Current Location | Shared Because |
|---|---|---|
Graph* |
components/graph/ |
Knowledge graph visualization |
Map* |
components/map/ |
Geographic visualization |
UML* |
components/uml/ |
Schema/ontology diagrams |
RDF* |
components/rdf/ |
RDF data visualization |
Timeline |
(new) | Temporal data display |
Site-Specific Components:
| Component | Site | Purpose |
|---|---|---|
CustodianCard |
bronhouder.nl | Heritage institution display |
OntologyExplorer |
bronhouder.nl | LinkML schema browser |
SparqlWorkbench |
bronhouder.nl | Query interface |
ArchiveSearch |
archief.support | Archival record search |
ResearchAssistant |
archief.support | AI-powered research help |
FamilyTreeViewer |
archief.support | Genealogical data viz |
2.2 Component Extraction Pattern
Before (monolithic):
// frontend/src/components/common/ErrorBoundary.tsx
export class ErrorBoundary extends React.Component { ... }
After (package):
// packages/ui/src/components/ErrorBoundary/ErrorBoundary.tsx
export class ErrorBoundary extends React.Component { ... }
// packages/ui/src/components/ErrorBoundary/index.ts
export { ErrorBoundary } from './ErrorBoundary';
export type { ErrorBoundaryProps } from './ErrorBoundary';
// packages/ui/src/index.ts
export * from './components/ErrorBoundary';
export * from './components/Tooltip';
// ... etc
Usage in app:
// apps/bronhouder/src/App.tsx
import { ErrorBoundary, Tooltip, Layout } from '@glam/ui';
import { useSparql, useGeocoding } from '@glam/hooks';
import { sparqlClient, geoClient } from '@glam/api-client';
Phase 3: Create Site Frontends (Week 3)
3.1 bronhouder.nl Frontend
apps/bronhouder/package.json:
{
"name": "@glam/bronhouder",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@glam/ui": "workspace:*",
"@glam/hooks": "workspace:*",
"@glam/api-client": "workspace:*",
"@glam/visualizations": "workspace:*",
"@glam/theme": "workspace:*",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.9.6",
"@mui/material": "^7.3.5",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@tanstack/react-query": "^5.90.10"
}
}
apps/bronhouder/src/App.tsx:
import { ThemeProvider } from '@mui/material';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { Layout, ErrorBoundary } from '@glam/ui';
import { bronhouderTheme } from '@glam/theme';
// Site-specific pages
import { HomePage } from './pages/Home';
import { CustodianExplorer } from './pages/CustodianExplorer';
import { OntologyViewer } from './pages/OntologyViewer';
import { SparqlWorkbench } from './pages/SparqlWorkbench';
const queryClient = new QueryClient();
export function App() {
return (
<ErrorBoundary>
<QueryClientProvider client={queryClient}>
<ThemeProvider theme={bronhouderTheme}>
<BrowserRouter>
<Layout
siteName="Bronhouder"
logo="/assets/bronhouder-logo.svg"
navigation={[
{ label: 'Home', path: '/' },
{ label: 'Custodians', path: '/custodians' },
{ label: 'Ontology', path: '/ontology' },
{ label: 'SPARQL', path: '/sparql' },
]}
>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/custodians/*" element={<CustodianExplorer />} />
<Route path="/ontology/*" element={<OntologyViewer />} />
<Route path="/sparql" element={<SparqlWorkbench />} />
</Routes>
</Layout>
</BrowserRouter>
</ThemeProvider>
</QueryClientProvider>
</ErrorBoundary>
);
}
3.2 archief.support Frontend (ArchiefAssistent)
apps/archief-assistent/package.json:
{
"name": "@glam/archief-assistent",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@glam/ui": "workspace:*",
"@glam/hooks": "workspace:*",
"@glam/api-client": "workspace:*",
"@glam/visualizations": "workspace:*",
"@glam/theme": "workspace:*",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.9.6",
"@mui/material": "^7.3.5",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@tanstack/react-query": "^5.90.10"
}
}
apps/archief-assistent/src/App.tsx:
import { ThemeProvider } from '@mui/material';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { Layout, ErrorBoundary } from '@glam/ui';
import { archiefTheme } from '@glam/theme';
// Site-specific pages
import { HomePage } from './pages/Home';
import { SearchPage } from './pages/Search';
import { AssistantPage } from './pages/Assistant';
import { CollectionsPage } from './pages/Collections';
import { GenealogyPage } from './pages/Genealogy';
const queryClient = new QueryClient();
export function App() {
return (
<ErrorBoundary>
<QueryClientProvider client={queryClient}>
<ThemeProvider theme={archiefTheme}>
<BrowserRouter>
<Layout
siteName="ArchiefAssistent"
logo="/assets/archief-logo.svg"
navigation={[
{ label: 'Home', path: '/' },
{ label: 'Zoeken', path: '/zoeken' },
{ label: 'Assistent', path: '/assistent' },
{ label: 'Collecties', path: '/collecties' },
{ label: 'Stamboom', path: '/stamboom' },
]}
>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/zoeken/*" element={<SearchPage />} />
<Route path="/assistent" element={<AssistantPage />} />
<Route path="/collecties/*" element={<CollectionsPage />} />
<Route path="/stamboom/*" element={<GenealogyPage />} />
</Routes>
</Layout>
</BrowserRouter>
</ThemeProvider>
</QueryClientProvider>
</ErrorBoundary>
);
}
Phase 4: Theming Strategy (Week 3)
4.1 Base Theme
packages/theme/src/base.ts:
import { createTheme, ThemeOptions } from '@mui/material';
export const baseThemeOptions: ThemeOptions = {
typography: {
fontFamily: '"Inter", "Roboto", "Helvetica", "Arial", sans-serif',
h1: { fontSize: '2.5rem', fontWeight: 600 },
h2: { fontSize: '2rem', fontWeight: 600 },
// ... etc
},
shape: {
borderRadius: 8,
},
components: {
MuiButton: {
styleOverrides: {
root: {
textTransform: 'none',
},
},
},
MuiCard: {
styleOverrides: {
root: {
boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
},
},
},
},
};
export const baseTheme = createTheme(baseThemeOptions);
4.2 Site-Specific Themes
packages/theme/src/bronhouder.ts:
import { createTheme, ThemeOptions } from '@mui/material';
import { baseThemeOptions } from './base';
import { deepmerge } from '@mui/utils';
const bronhouderOverrides: ThemeOptions = {
palette: {
primary: {
main: '#1976d2', // Blue - professional, data-focused
light: '#42a5f5',
dark: '#1565c0',
},
secondary: {
main: '#7c4dff', // Purple - ontology/knowledge
light: '#b47cff',
dark: '#3f1dcb',
},
background: {
default: '#f5f7fa',
paper: '#ffffff',
},
},
};
export const bronhouderTheme = createTheme(
deepmerge(baseThemeOptions, bronhouderOverrides)
);
packages/theme/src/archief.ts:
import { createTheme, ThemeOptions } from '@mui/material';
import { baseThemeOptions } from './base';
import { deepmerge } from '@mui/utils';
const archiefOverrides: ThemeOptions = {
palette: {
primary: {
main: '#2e7d32', // Green - archival, heritage
light: '#60ad5e',
dark: '#005005',
},
secondary: {
main: '#ff8f00', // Orange - attention, assistance
light: '#ffc046',
dark: '#c56000',
},
background: {
default: '#fafaf5', // Warm paper-like background
paper: '#ffffff',
},
},
};
export const archiefTheme = createTheme(
deepmerge(baseThemeOptions, archiefOverrides)
);
Phase 5: Infrastructure Updates (Week 4)
5.1 Updated Caddyfile
infrastructure/caddy/Caddyfile:
# Shared API routes snippet
(api_routes) {
# DuckLake - Time travel analytics
handle /ducklake/* {
reverse_proxy 127.0.0.1:8765
}
# FastAPI general API
handle /api/* {
reverse_proxy 127.0.0.1:8000
}
# PostgreSQL API
handle /api/postgres/* {
reverse_proxy 127.0.0.1:8001
}
# Geographic API
handle /api/geo/* {
reverse_proxy 127.0.0.1:8002
}
# TypeDB API
handle /api/typedb/* {
reverse_proxy 127.0.0.1:8003
}
# RAG API
handle /api/rag/* {
reverse_proxy 127.0.0.1:8004
}
# Semantic Cache
handle /api/cache/* {
reverse_proxy 127.0.0.1:8090
}
# Qdrant vector database
handle /qdrant/* {
reverse_proxy 127.0.0.1:6333
}
# SPARQL endpoint
handle /query {
reverse_proxy 127.0.0.1:7878
}
handle /sparql/* {
uri strip_prefix /sparql
reverse_proxy 127.0.0.1:7878
}
}
# Static file serving snippet (parameterized)
(static_files) {
try_files {path} /index.html
file_server
encode gzip
header {
X-Content-Type-Options nosniff
X-Frame-Options DENY
Referrer-Policy strict-origin-when-cross-origin
}
}
# =============================================================================
# bronhouder.nl - Heritage Custodian Portal
# =============================================================================
bronhouder.nl, www.bronhouder.nl {
log {
output file /var/log/caddy/bronhouder.log
format json
}
# Import shared API routes
import api_routes
# Health check
handle /health {
respond "OK" 200
}
# Static frontend files
handle {
root * /var/www/bronhouder
import static_files
}
}
# SPARQL subdomain for bronhouder
sparql.bronhouder.nl {
reverse_proxy 127.0.0.1:7878 {
header_up Host {http.request.host}
header_up X-Real-IP {http.request.remote.host}
}
}
# =============================================================================
# archief.support - ArchiefAssistent (National Archives Services)
# =============================================================================
archief.support, www.archief.support {
log {
output file /var/log/caddy/archief-support.log
format json
}
# Import shared API routes
import api_routes
# Health check
handle /health {
respond "OK" 200
}
# Static frontend files
handle {
root * /var/www/archief-assistent
import static_files
}
}
# API subdomain for archief.support (optional)
api.archief.support {
import api_routes
handle {
respond "ArchiefAssistent API" 200
}
}
5.2 Updated deploy.sh
infrastructure/deploy.sh (additions):
#!/bin/bash
set -euo pipefail
# ... existing code ...
BRONHOUDER_FRONTEND_PATH="/var/www/bronhouder"
ARCHIEF_FRONTEND_PATH="/var/www/archief-assistent"
# Parse --site argument
SITE=""
while [[ $# -gt 0 ]]; do
case $1 in
--site=*)
SITE="${1#*=}"
shift
;;
--frontend)
DEPLOY_FRONTEND=true
shift
;;
# ... other flags ...
esac
done
deploy_frontend() {
local site="$1"
local source_dir=""
local target_path=""
case "$site" in
bronhouder)
source_dir="apps/bronhouder"
target_path="$BRONHOUDER_FRONTEND_PATH"
;;
archief|archief-assistent)
source_dir="apps/archief-assistent"
target_path="$ARCHIEF_FRONTEND_PATH"
;;
all)
deploy_frontend "bronhouder"
deploy_frontend "archief-assistent"
return
;;
*)
echo "Unknown site: $site"
echo "Valid sites: bronhouder, archief-assistent, all"
exit 1
;;
esac
echo "=== Deploying $site frontend ==="
# Build
echo "Building $site..."
cd "$source_dir"
pnpm install
pnpm build
# Deploy
echo "Deploying to $target_path..."
rsync -avz --delete dist/ "root@${SERVER_IP}:${target_path}/"
cd ../..
echo "=== $site frontend deployed ==="
}
# In main logic
if [[ "$DEPLOY_FRONTEND" == "true" ]]; then
if [[ -z "$SITE" ]]; then
echo "Error: --frontend requires --site=<name>"
echo "Usage: ./deploy.sh --frontend --site=bronhouder"
echo " ./deploy.sh --frontend --site=archief-assistent"
echo " ./deploy.sh --frontend --site=all"
exit 1
fi
deploy_frontend "$SITE"
fi
Usage:
# Deploy bronhouder.nl frontend only
./infrastructure/deploy.sh --frontend --site=bronhouder
# Deploy archief.support frontend only
./infrastructure/deploy.sh --frontend --site=archief-assistent
# Deploy both frontends
./infrastructure/deploy.sh --frontend --site=all
Component Sharing Patterns
Pattern 1: Polymorphic Components
Design components that adapt based on props:
// packages/ui/src/components/Layout/Layout.tsx
interface LayoutProps {
siteName: string;
logo: string;
navigation: NavItem[];
children: React.ReactNode;
variant?: 'default' | 'compact' | 'fullwidth';
footer?: React.ReactNode;
}
export function Layout({
siteName,
logo,
navigation,
children,
variant = 'default',
footer
}: LayoutProps) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
<Navigation siteName={siteName} logo={logo} items={navigation} />
<Box component="main" sx={layoutVariants[variant]}>
{children}
</Box>
{footer ?? <DefaultFooter siteName={siteName} />}
</Box>
);
}
Pattern 2: Composition Over Inheritance
Create building blocks that can be composed differently:
// packages/ui/src/components/Card/Card.tsx
export function Card({ children, ...props }: CardProps) { ... }
export function CardHeader({ title, subtitle, actions }: CardHeaderProps) { ... }
export function CardContent({ children }: CardContentProps) { ... }
export function CardActions({ children }: CardActionsProps) { ... }
// apps/bronhouder - Custodian card
import { Card, CardHeader, CardContent } from '@glam/ui';
export function CustodianCard({ custodian }) {
return (
<Card>
<CardHeader
title={custodian.name}
subtitle={custodian.type}
actions={<BookmarkButton />}
/>
<CardContent>
<CustodianDetails custodian={custodian} />
</CardContent>
</Card>
);
}
// apps/archief-assistent - Archive record card
import { Card, CardHeader, CardContent } from '@glam/ui';
export function ArchiveRecordCard({ record }) {
return (
<Card>
<CardHeader
title={record.title}
subtitle={record.dateRange}
actions={<RequestButton />}
/>
<CardContent>
<RecordMetadata record={record} />
</CardContent>
</Card>
);
}
Pattern 3: Render Props / Slots
Allow customization through slots:
// packages/visualizations/src/Graph/Graph.tsx
interface GraphProps<T> {
data: T[];
renderNode?: (node: T) => React.ReactNode;
renderEdge?: (edge: Edge) => React.ReactNode;
renderTooltip?: (node: T) => React.ReactNode;
}
export function Graph<T>({
data,
renderNode = DefaultNode,
renderEdge = DefaultEdge,
renderTooltip = DefaultTooltip
}: GraphProps<T>) {
// Graph implementation using D3/ELK
return (
<svg>
{edges.map(edge => renderEdge(edge))}
{nodes.map(node => renderNode(node))}
</svg>
);
}
Pattern 4: Feature Flags
Control feature visibility per site:
// packages/ui/src/contexts/FeatureFlagsContext.tsx
interface FeatureFlags {
sparqlWorkbench: boolean;
genealogyTools: boolean;
aiAssistant: boolean;
advancedFilters: boolean;
}
const defaultFlags: Record<string, FeatureFlags> = {
bronhouder: {
sparqlWorkbench: true,
genealogyTools: false,
aiAssistant: false,
advancedFilters: true,
},
archiefAssistent: {
sparqlWorkbench: false,
genealogyTools: true,
aiAssistant: true,
advancedFilters: true,
},
};
// Usage
import { useFeatureFlags } from '@glam/ui';
function Navigation() {
const { sparqlWorkbench, aiAssistant } = useFeatureFlags();
return (
<nav>
{sparqlWorkbench && <NavLink to="/sparql">SPARQL</NavLink>}
{aiAssistant && <NavLink to="/assistant">AI Assistant</NavLink>}
</nav>
);
}
Migration Strategy
Step 1: Setup (Day 1-2)
- Install pnpm:
npm install -g pnpm - Create workspace configuration
- Create package directory structure
- Set up TypeScript project references
Step 2: Extract Shared Code (Day 3-5)
- Move UI components to
packages/ui - Move hooks to
packages/hooks - Move API clients to
packages/api-client - Move visualization components to
packages/visualizations - Update imports in existing frontend
Step 3: Create Second Frontend (Day 6-8)
- Create
apps/archief-assistentscaffold - Set up routing and pages
- Apply archief theme
- Implement AA-specific features
Step 4: Infrastructure (Day 9-10)
- Update Caddyfile for multi-domain
- Update deploy.sh for multi-site
- Set up DNS for archief.support
- Test deployments
Step 5: Testing & Polish (Day 11-14)
- E2E tests for both sites
- Performance optimization
- Accessibility audit
- Documentation
Development Workflow
Daily Development
# Terminal 1: Develop bronhouder.nl
pnpm dev:bronhouder
# Terminal 2: Develop archief.support
pnpm dev:archief
# Terminal 3: Backend services
./infrastructure/deploy.sh --status
Making Changes to Shared Packages
# Changes to packages/* are immediately available to apps
# because we use workspace:* references
# If you modify packages/ui/src/components/Button.tsx
# Both apps will see the changes on next hot reload
Deploying
# Deploy specific site
./infrastructure/deploy.sh --frontend --site=bronhouder
# Deploy both sites
./infrastructure/deploy.sh --frontend --site=all
# Deploy everything (backend + both frontends)
./infrastructure/deploy.sh --all --site=all
Summary
| Aspect | Approach |
|---|---|
| Monorepo Tool | pnpm workspaces |
| Package Structure | packages/* (shared), apps/* (sites) |
| Shared Components | @glam/ui, @glam/hooks, @glam/api-client, @glam/visualizations, @glam/theme |
| Theming | Base theme + site-specific overrides |
| Deployment | SSH/rsync per site, Caddy multi-domain |
| Development | Parallel Vite dev servers |
| Type Safety | TypeScript project references |
This architecture provides maximum code reuse while allowing full customization per site, with clear separation of concerns and independent deployment capability.