SUPER SITE
Setup, Architecture & Production Blueprint
A consolidated guide to the configurable React experience, persistent administration service, real-time updates, testing, containers, and delivery pipeline.
Architecture baseline • August 2026\ Reconstructed from the requested scope and available conversation preview
How to use this document
Outcome. SUPER SITE is treated as a data-driven application, not a collection of hand-tuned pages. Administrators publish validated configuration; route-aware selectors resolve it; React components render the result; WebSocket events synchronize active sessions.
The earlier thread itself could not be fully retrieved while this document was generated. The cached exchange established the request for a consolidated archive, while the detailed scope supplied here identifies the intended features. Accordingly, this guide preserves the likely evolution and useful reasoning, but does not claim that reconstructed wording is a verbatim transcript.
Contents
- Evolution, decisions, and quality attributes
- System context and configuration model
- React frontend and route resolution
- MoviesScenesGrid and SearchResultsPage integration
- Visual controls, SVG motion, effects, loaders, and banners ode.js API and WebSocket updates
- Banner management and authorization
- Local mock/testing workflow
- Docker, environments, and GitLab CI/CD
- Folder structure, setup, run, and deploy
- Prototype boundary and production hardening
1. Evolution and architectural reasoning
From page tweaks to a configuration platform
The design naturally evolves through four stages:
- Static UI prototype. Grid columns, poster placement, player size, gradients, and animations are coded directly into React components. This proves the visual language quickly.
- Shared runtime settings. Common values move into a context so the same controls drive multiple components without prop drilling.
- Route-specific overrides. A global default remains the safe baseline; route keys override only the fields that differ. This avoids copying a whole configuration per page.
- Persistent, governed configuration. A server stores versioned configuration, validates changes, enforces roles, records audit history, and broadcasts publication events.
The key reasoning is separation of concerns: presentation components should consume resolved values; they should not know whether a value came from defaults, a route override, a user preference, a feature flag, or a persistent administration service.
Quality attributes
| Attribute | Design response |
| Consistency | One schema and one resolver feed all pages and visual controls. |
| Safe change | Draft → validate → preview → publish; defaults and rollback remain available. |
| Performance | CSS variables for cosmetic updates, memoized selectors, bounded WebSocket messages, cached published config. |
| Accessibility | Reduced motion, keyboard focus, semantic controls, non-color loading states, contrast checks. |
| Operability | Health endpoints, structured logs, version metadata, metrics, immutable container images. |
| Security | Server-side authorization, schema validation, audit events, protected secrets, restricted CORS. |
2. System context
Browser React application ── GET /api/config/published ──► Node.js API ──► PostgreSQL │ ▲ │ config, users, │ │ └── audit log banners, versions ├── resolved route config│ ├── admin editor ── PUT /api/admin/config/draft └── WebSocket ◄──── config.published / banner.changed GitLab CI ── lint / test / build / scan ──► image registry ──► staging ──► production
Configuration precedence
A predictable merge order prevents surprising UI behavior:
compiled defaults
→ published global settings
→ matching route override (exact, then declared pattern)
→ optional user preference (only whitelisted keys)
→ ephemeral preview patch (admin preview mode only)
Arrays should normally replace rather than merge. Objects merge deeply only for schema-declared sections. An override set to null must have a defined meaning—prefer “remove override and inherit” rather than “render null.”
Canonical TypeScript model
// packages/config/src/schema.ts
import { z } from 'zod';
export const uiSettingsSchema = z.object({
grid: z.object({
minColumns: z.number().int().min(1).max(12).default(2),
maxColumns: z.number().int().min(1).max(12).default(6),
gapPx: z.number().int().min(0).max(64).default(16),
cardMinWidthPx: z.number().int().min(120).max(600).default(220),
}).refine(v => v.minColumns <= v.maxColumns, 'minColumns must be <= maxColumns'),
poster: z.object({
visible: z.boolean().default(true),
position: z.enum(
aspectRatio: z.enum(
}),
player: z.object({
width: z.string().regex(/^\d+(px|%|vw)$/).default('100%'),
maxWidth: z.string().regex(/^\d+(px|%|vw)$/).default('1280px'),
aspectRatio: z.enum(
}),
motion: z.object({
enabled: z.boolean().default(true),
preset: z.enum(
hoverScale: z.number().min(1).max(1.12).default(1.025),
durationMs: z.number().int().min(80).max(1200).default(220),
}),
effects: z.object({
scrollReveal: z.boolean().default(true),
filterTransition: z.boolean().default(true),
gradientBanner: z.boolean().default(true),
}),
});
export type UiSettings = z.infer<typeof uiSettingsSchema>;
export type UiSettingsPatch = PartialDeep<UiSettings>;
export const siteConfigSchema = z.object({
version: z.number().int().nonnegative(),
updatedAt: z.string().datetime(),
defaults: uiSettingsSchema,
routes: z.record(z.string(), uiSettingsSchema.deepPartial()),
banners: z.array(z.object({
id: z.string().uuid(), active: z.boolean(), priority: z.number().int(),
message: z.string().min(1).max(240), href: z.string().url().optional(),
routes: z.array(z.string()).default(
startsAt: z.string().datetime().nullable(), endsAt: z.string().datetime().nullable(),
gradient: z.tuple(
})),
});
3. React frontend architecture
Provider stack and responsibilities
<ErrorBoundary>
<QueryClientProvider>
<AuthProvider>
<AdminConfigProvider>
<RouterProvider router={router} />
</AdminConfigProvider>
</AuthProvider>
</QueryClientProvider>
</ErrorBoundary>
Server state belongs in a query/cache layer; authenticated identity belongs in auth state; the configuration provider owns resolution, preview patches, and WebSocket reconciliation. Page components receive resolved settings via a hook and remain ignorant of transport details.
Defaults and route resolver
// apps/web/src/config/defaults.ts
export const DEFAULT_UI: UiSettings = {
grid: { minColumns: 2, maxColumns: 6, gapPx: 16, cardMinWidthPx: 220 },
poster: { visible: true, position: 'before-player', aspectRatio: '2/3' },
player: { width: '100%', maxWidth: '1280px', aspectRatio: '16/9' },
motion: { enabled: true, preset: 'subtle', hoverScale: 1.025, durationMs: 220 },
effects: { scrollReveal: true, filterTransition: true, gradientBanner: true },
};
const ROUTE_ORDER =
export function resolveUiSettings(
pathname: string, config: SiteConfig, preview?: UiSettingsPatch
): UiSettings {
const key = ROUTE_ORDER.find(pattern => matchPath({ path: pattern, end: true }, pathname)) ?? '*';
return uiSettingsSchema.parse(deepMerge(
DEFAULT_UI,
config.defaults,
config.routes
preview ?? {},
));
}
// Merge only plain objects. Arrays and primitives replace existing values.
export function deepMerge<T>(…sources: unknown[]): T {
return sources.reduce((target: any, source: any) => {
if (!source || typeof source !== 'object' || Array.isArray(source)) return source ?? target;
const out = { …(target ?? {}) };
for (const
out
? deepMerge(out
}
return out;
}, {}) as T;
}
Route keys are contracts
Do not store arbitrary URLs as keys if the router uses parameterized routes. Store stable patterns such as /movies/:movieId/scenes. Declare matching order because overlapping patterns are otherwise ambiguous. In production, share route identifiers between the router and admin schema so renaming a path cannot silently orphan settings.
4. AdminConfigContext
// apps/web/src/config/AdminConfigContext.tsx
type ConfigContextValue = {
published: SiteConfig;
resolved: UiSettings;
previewPatch: UiSettingsPatch | null;
status: 'loading' | 'ready' | 'stale' | 'error';
setPreviewPatch(patch: UiSettingsPatch | null): void;
refresh(): Promise<void>;
};
const ConfigContext = createContext<ConfigContextValue | null>(null);
export function AdminConfigProvider({ children }: PropsWithChildren) {
const { pathname } = useLocation();
const
const
const
const refresh = useCallback(async () => {
const response = await fetch('/api/config/published', { credentials: 'include' });
if (!response.ok) throw new Error(`Config fetch failed: ${response.status}`);
const next = siteConfigSchema.parse(await response.json());
setPublished(current => !current || next.version >= current.version ? next : current);
setStatus('ready');
}, []);
useEffect(() => { refresh().catch(() => setStatus('error')); },
useEffect(() => connectConfigSocket({
onPublished: event => setPublished(current =>
!current || event.config.version > current.version ? event.config : current),
onDisconnect: () => setStatus('stale'),
}), []);
const effective = published ?? { version: 0, updatedAt: new Date(0).toISOString(),
defaults: DEFAULT_UI, routes: {}, banners: [] };
const resolved = useMemo(
() => resolveUiSettings(pathname, effective, previewPatch ?? undefined),
);
return <ConfigContext.Provider value={{ published: effective, resolved,
previewPatch, status, setPreviewPatch, refresh }}>{children}</ConfigContext.Provider>;
}
export function useUiSettings() {
const value = useContext(ConfigContext);
if (!value) throw new Error('useUiSettings must be inside AdminConfigProvider');
return value;
}
The context keeps the last valid configuration when connectivity drops. A stale badge may appear in the admin UI, but the public site should continue rendering. Incoming versions lower than the current version are ignored to prevent WebSocket reordering from rolling the UI backward.
5. MoviesScenesGrid and SearchResultsPage
One grid primitive, two integrations
// apps/web/src/components/MediaGrid.tsx
export function MediaGrid({ children, ariaLabel }: PropsWithChildren<{ ariaLabel: string }>) {
const { resolved } = useUiSettings();
const style = {
'–grid-min': resolved.grid.minColumns,
'–grid-max': resolved.grid.maxColumns,
'–grid-gap': `${resolved.grid.gapPx}px`,
'–card-min': `${resolved.grid.cardMinWidthPx}px`,
} as CSSProperties;
return <section className="media-grid" style={style} aria-label={ariaLabel}>{children}</section>;
}
// apps/web/src/pages/MoviesScenesGrid.tsx
export function MoviesScenesGrid({ scenes }: { scenes: Scene[] }) {
return <MediaGrid ariaLabel="Movie scenes">
{scenes.map(scene => <SceneCard key={scene.id} scene={scene} />)}
</MediaGrid>;
}
// apps/web/src/pages/SearchResultsPage.tsx
export function SearchResultsPage() {
const { data, isLoading } = useSearchResults();
if (isLoading) return <ResultsSkeleton />;
return <main><SearchToolbar /><MediaGrid ariaLabel="Search results">
{data.items.map(item => <MediaCard key={`${item.kind}:${item.id}`} item={item} />)}
</MediaGrid></main>;
}
/* apps/web/src/styles/media-grid.css */
.media-grid {
display:grid; gap:var(–grid-gap);
grid-template-columns:repeat(auto-fit, minmax(min(100%, var(–card-min)), 1fr));
}
@media (min-width: 720px) {
.media-grid { grid-template-columns:repeat(var(–grid-min), minmax(0,1fr)); }
}
@media (min-width: 1280px) {
.media-grid { grid-template-columns:repeat(var(–grid-max), minmax(0,1fr)); }
}
The same primitive prevents divergence between scenes and search. If the two pages need different columns, configure route overrides; do not fork the component. Clamp card width and column counts in the schema to stop an administrator from producing unusable layouts.
PosterContainer visibility and position
export function MediaDetail({ item }: { item: MediaItem }) {
const { resolved: { poster, player } } = useUiSettings();
const posterNode = poster.visible ? <PosterContainer item={item} mode={poster.position} /> : null;
return <div className={`media-detail poster-${poster.position}`}>
{poster.position === 'before-player' && posterNode}
<PlayerContainer source={item.source} settings={player} />
{poster.position === 'after-player' && posterNode}
{poster.position === 'overlay' && posterNode}
</div>;
}
Conditional rendering is preferred over hiding with CSS: hidden posters should not remain in the accessibility tree or download large images. Overlay mode needs a containing block, an explicit stacking order, readable focus outlines, and a mobile fallback when the overlay would obscure player controls.
PlayerContainer sizing
export function PlayerContainer({ source, settings }: PlayerProps) {
return <div className="player-shell" style={{
width: settings.width,
maxWidth: settings.maxWidth,
aspectRatio: settings.aspectRatio,
}}><video controls preload="metadata" src={source} /></div>;
}
.player-shell { margin-inline:auto; position:relative; background:#05070a; }
.player-shell > video { width:100%; height:100%; object-fit:contain; display:block; }
@media (max-width:600px) { .player-shell { width:100% !important; max-width:100% !important; } }
Allow only schema-validated CSS lengths rather than arbitrary styles. A production system can make sizing even safer by storing typed numbers plus units instead of raw strings.
6. Motion, effects, loaders, and banners
Reusable SVG animation
export function OrbitMark({ title = 'Loading' }: { title?: string }) {
const id = useId();
return <svg viewBox="0 0 64 64" role="img" aria-labelledby={id} className="orbit-mark">
<title id={id}>{title}</title>
<circle cx="32" cy="32" r="22" className="orbit-track" />
<path d="M32 10a22 22 0 0 1 22 22" className="orbit-head" />
</svg>;
}
.orbit-track,.orbit-head { fill:none; stroke-width:4; }
.orbit-track { stroke:currentColor; opacity:.18; }
.orbit-head { stroke:currentColor; stroke-linecap:round; transform-origin:32px 32px;
animation:orbit 900ms linear infinite; }
@keyframes orbit { to { transform:rotate(360deg); } }
@media (prefers-reduced-motion:reduce) { .orbit-head { animation:none; opacity:.75; } }
SVG components should inherit currentColor, expose an accessible label when meaningful, use stable view boxes, and avoid hard-coded instance IDs. Decorative SVGs instead use aria-hidden="true".
Scroll reveal, hover, and filter transitions
export function useReveal(enabled: boolean) {
const ref = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!enabled || !ref.current) return;
const node = ref.current;
const observer = new IntersectionObserver((
if (entry.isIntersecting) { node.dataset.visible = 'true'; observer.disconnect(); }
}, { rootMargin: '0px 0px -8% 0px', threshold: .08 });
observer.observe(node); return () => observer.disconnect();
},
return ref;
}
.reveal { opacity:0; transform:translateY(10px); transition:opacity 260ms ease,transform 260ms ease; }
.reveal
.media-card { transition:transform var(–motion-duration) cubic-bezier(.2,.8,.2,1), box-shadow 180ms ease; }
.media-card:hover { transform:translateY(-2px) scale(var(–hover-scale)); }
.media-card:focus-within { outline:2px solid var(–focus); outline-offset:3px; }
@media (prefers-reduced-motion:reduce) { .reveal,.media-card { transition:none; transform:none; } }
Filter effects should animate opacity or transform, not height-heavy layouts. Keep the previous result set visible while the next request is in flight; announce the new result count through an aria-live="polite" region. Never make hover the sole carrier of information.
Loading and progress states
- Initial page load: use skeletons that approximate final geometry to reduce layout shift.
- Inline action: keep the button label, add a compact spinner, disable duplicate submission.
- Known-duration upload/import: expose a determinate progress bar with numeric value.
- Unknown-duration work: use an indeterminate indicator and plain-language status.
export function Progress({ value, label }: { value?: number; label: string }) {
const determinate = Number.isFinite(value);
return <div className="progress-wrap"><span>{label}</span>
<div className="progress" role="progressbar" aria-label={label}
aria-valuemin={determinate ? 0 : undefined} aria-valuemax={determinate ? 100 : undefined}
aria-valuenow={determinate ? Math.round(value!) : undefined}>
<i className={determinate ? '' : 'indeterminate'} style={determinate ? { width:`${value}%` } : {}} />
</div></div>;
}
Gradient banner
export function SiteBanner() {
const { published } = useUiSettings();
const banner = selectActiveBanner(published.banners, location.pathname, new Date());
if (!banner) return null;
return <aside className="site-banner" style={{
'–banner-from': banner.gradient
} as CSSProperties}>
{banner.href ? <a href={banner.href}>{banner.message}</a> : banner.message}
</aside>;
}
.site-banner { color:#fff; padding:10px 16px; text-align:center;
background:linear-gradient(105deg,var(–banner-from),var(–banner-to)); }
.site-banner a { color:inherit; font-weight:700; text-underline-offset:3px; }
7. Administration UI
Editing workflow
- Select global defaults or a named route.
- Edit typed controls: bounded number inputs, toggles, segmented position controls, and validated color fields.
- Preview in an isolated frame or preview session without modifying the public published version.
- Validate locally and on the server.
- Save a draft with optimistic concurrency.
- Publish after permission and confirmation checks.
- Observe the new version propagate; retain rollback to a prior immutable version.
async function saveDraft(baseVersion: number, patch: SiteConfigPatch) {
const response = await fetch('/api/admin/config/draft', {
method: 'PUT', credentials: 'include',
headers: { 'content-type':'application/json', 'if-match': String(baseVersion) },
body: JSON.stringify(patch),
});
if (response.status === 409) throw new ConfigConflictError(await response.json());
if (!response.ok) throw new Error('Draft save failed');
return siteConfigSchema.parse(await response.json());
}
If-Match (or an equivalent base-version field) prevents two administrators from silently overwriting each other. A conflict screen should show which version changed and offer reload or an explicit merge.
8. Persistent Node.js API
Suggested endpoints
| Method | Path | Access | Purpose |
| GET | /health/live | Public/internal | Process liveness |
| GET | /health/ready | Internal | Database and dependency readiness |
| GET | /api/config/published | Public | Current public configuration + version/ETag |
| GET | /api/admin/config/draft | Editor+ | Current draft |
| PUT | /api/admin/config/draft | Editor+ | Validate and save draft |
| POST | /api/admin/config/publish | Publisher+ | Create immutable published version |
| POST | /api/admin/config/rollback/:version | Admin | Republish a historical version as a new version |
| CRUD | /api/admin/banners | Editor+ | Manage scheduled, route-targeted banners |
Fastify service example
// apps/api/src/server.ts
const app = Fastify({ logger: true, trustProxy: true });
await app.register(cookie);
await app.register(cors, { origin: env.WEB_ORIGINS, credentials: true });
await app.register(rateLimit, { max: 200, timeWindow: '1 minute' });
app.get('/api/config/published', async (req, reply) => {
const config = await configRepo.getPublished();
const etag = `W/"config-${config.version}"`;
if (req.headers
return reply.header('etag', etag).header('cache-control','public,max-age=30,stale-while-revalidate=300').send(config);
});
app.put('/api/admin/config/draft', {
preHandler:
}, async (req, reply) => {
const baseVersion = Number(req.headers
const patch = siteConfigSchema.deepPartial().parse(req.body);
const draft = await db.transaction(async tx => {
const current = await configRepo.getDraft(tx, { forUpdate: true });
if (current.version !== baseVersion) throw new ConflictError(current.version);
const next = siteConfigSchema.parse(deepMerge(current.document, patch));
const saved = await configRepo.saveDraft(tx, next, req.user.id);
await auditRepo.append(tx, { actorId:req.user.id, action:'config.draft.saved',
entityId:String(saved.version), before:current.document, after:saved.document });
return saved;
});
return reply.send(draft.document);
});
app.post('/api/admin/config/publish', {
preHandler:
}, async (req, reply) => {
const published = await publishService.publish(req.user.id);
socketHub.broadcast({ type:'config.published', version:published.version, config:published });
return reply.code(201).send(published);
});
Persistence model
CREATE TABLE config_versions (
version BIGSERIAL PRIMARY KEY,
status TEXT NOT NULL CHECK (status IN ('draft','published','archived')),
document JSONB NOT NULL,
created_by UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
published_at TIMESTAMPTZ
);
CREATE UNIQUE INDEX one_draft ON config_versions(status) WHERE status='draft';
CREATE INDEX published_versions ON config_versions(published_at DESC) WHERE status='published';
CREATE TABLE audit_events (
id UUID PRIMARY KEY, actor_id UUID REFERENCES users(id), action TEXT NOT NULL,
entity_type TEXT NOT NULL, entity_id TEXT NOT NULL,
before_data JSONB, after_data JSONB, request_id TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Configuration JSONB keeps schema-shaped documents easy to version, while relational tables remain preferable for users, roles, sessions, and banners when they require rich querying. Validate both at the API boundary and before persistence.
9. WebSocket real-time updates
// Server message envelope
type ServerEvent =
| { type:'hello'; latestVersion:number; heartbeatMs:number }
| { type:'config.published'; version:number; config:SiteConfig }
| { type:'banner.changed'; version:number }
| { type:'error'; code:string; message:string };
// Browser connector with backoff and version recovery
export function connectConfigSocket(handlers: Handlers) {
let socket: WebSocket | undefined; let attempt = 0; let stopped = false;
const open = () => {
socket = new WebSocket(`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws/config`);
socket.onopen = () => { attempt = 0; };
socket.onmessage = event => {
const message = serverEventSchema.parse(JSON.parse(event.data));
if (message.type === 'config.published') handlers.onPublished(message);
if (message.type === 'hello') handlers.onHello?.(message);
};
socket.onclose = () => {
handlers.onDisconnect?.();
if (!stopped) setTimeout(open, Math.min(30_000, 500 * 2 ** attempt++) + Math.random()*400);
};
};
open(); return () => { stopped = true; socket?.close(1000, 'unmount'); };
}
Broadcast publication events, not every keystroke. In a multi-instance API deployment, use PostgreSQL LISTEN/NOTIFY, Redis pub/sub, or another broker so clients connected to different pods receive the same event. On reconnect, compare versions and fetch the published REST resource if a gap exists. Apply message-size limits, origin checks, authentication for admin channels, heartbeat/idle timeouts, and per-connection rate limits.
10. Banner management and roles
Banner selection rules
- Active flag is true.
- Current time falls between optional start/end boundaries, evaluated in UTC.
- Route pattern matches.
- Highest priority wins; ties use newest update, then stable ID.
Validate message length, URLs, route patterns, gradient colors, and schedule order. For urgent operational banners, support an expiration time by default so old incidents do not stay visible indefinitely.
Role matrix
| Capability | Viewer | Editor | Publisher | Admin |
| View published settings | Yes | Yes | Yes | Yes |
| View draft/history | Optional | Yes | Yes | Yes |
| Edit draft/banner | No | Yes | Yes | Yes |
| Publish | No | No | Yes | Yes |
| Rollback, manage roles | No | No | No | Yes |
Permissions must be enforced server-side on every operation. Hiding a button is only presentation. Prefer identity-provider groups mapped to application roles; use short-lived sessions or tokens and CSRF protection when cookie authentication is used.
11. Local mock and testing setup
Mock transport
// apps/web/src/mocks/handlers.ts (MSW)
export const handlers = [
http.get('/api/config/published', () => HttpResponse.json(seedConfig)),
http.put('/api/admin/config/draft', async ({ request }) => {
const patch = await request.json();
return HttpResponse.json(siteConfigSchema.parse(deepMerge(seedConfig, patch)));
}),
];
// Enable only in development
if (import.meta.env.DEV && import.meta.env.VITE_USE_MOCKS === 'true') {
const { worker } = await import('./mocks/browser'); await worker.start();
}
Test pyramid
- Unit: schema boundaries, deep merge semantics, route precedence, banner scheduling, permission guards.
- Component: column CSS variables, hidden poster removal, player sizing, reduced-motion behavior, admin validation errors.
- API integration: draft conflict, transaction rollback, publish event, authorization, ETag/304.
- End to end: editor saves draft; publisher publishes; a second browser receives the WebSocket update; rollback restores a prior appearance.
- Visual/accessibility: representative viewports, keyboard navigation, axe checks, contrast, no animation under reduced motion.
it('applies exact route override after defaults', () => {
const resolved = resolveUiSettings('/search', {
…seedConfig,
defaults: { …DEFAULT_UI, grid:{ …DEFAULT_UI.grid, maxColumns:5 } },
routes: { '/search': { grid:{ maxColumns:8 } } },
});
expect(resolved.grid.maxColumns).toBe(8);
expect(resolved.grid.gapPx).toBe(DEFAULT_UI.grid.gapPx);
});
12. Recommended folder structure
super-site/
├─ apps/
│ ├─ web/
│ │ ├─ src/{app,components,config,features,pages,mocks,styles,test}/
│ │ ├─ public/ Dockerfile vite.config.ts
│ └─ api/
│ ├─ src/{auth,config,banners,db,http,ws,observability,test}/
│ ├─ migrations/ Dockerfile
├─ packages/
│ ├─ config/ # shared Zod schema, types, defaults, route IDs
│ ├─ ui/ # reusable primitives and SVG components
│ ├─ eslint-config/ # organization lint rules
│ └─ test-utils/
├─ deploy/
│ ├─ compose/ kubernetes/ nginx/
├─ .gitlab-ci.yml compose.yaml package.json pnpm-workspace.yaml
└─ .env.example README.md
A monorepo makes the schema and event contracts shareable without publishing them externally. Keep feature logic close to the feature; reserve the UI package for genuinely reused, application-agnostic primitives.
13. Environment configuration
| Variable | Example | Notes |
| NODE_ENV | production | Runtime mode, never a secret |
| PORT | 8080 | API listen port |
| DATABASE_URL | postgres://… | Secret; inject from protected store |
| WEB_ORIGINS | https://staging.example.com | Explicit comma-separated origins |
| SESSION_SECRET | … | Long random secret; rotate deliberately |
| LOG_LEVEL | info | Use debug only temporarily |
| VITE_API_BASE_URL | /api | Build-time public value |
| VITE_WS_URL | wss://…/ws/config | Optional if same-origin |
| VITE_USE_MOCKS | false | Development only; fail production build if true |
Commit .env.example, not real secrets. Validate environment variables at startup and exit with a clear error when required values are missing.
14. Docker: local, staging, and production
# apps/api/Dockerfile
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY apps/api/package.json apps/api/package.json
COPY packages/config/package.json packages/config/package.json
RUN corepack enable && pnpm install –frozen-lockfile
FROM deps AS build
COPY . .
RUN pnpm –filter @supersite/api… build && pnpm deploy –filter @supersite/api –prod /out
FROM node:22-alpine AS runtime
ENV NODE_ENV=production
RUN addgroup -S app && adduser -S app -G app
WORKDIR /app
COPY –from=build –chown=app:app /out ./
USER app
EXPOSE 8080
HEALTHCHECK –interval=30s –timeout=3s CMD wget -qO- http://127.0.0.1:8080/health/live || exit 1
CMD
# compose.yaml (local development shape)
services:
db:
image: postgres:17-alpine
environment:
POSTGRES_DB: supersite
POSTGRES_USER: supersite
POSTGRES_PASSWORD: local-only
volumes:
healthcheck: { test:
api:
build: { context: ., dockerfile: apps/api/Dockerfile, target: build }
environment:
NODE_ENV: development
DATABASE_URL: postgres://supersite:local-only@db:5432/supersite
WEB_ORIGINS: http://localhost:5173
depends_on: { db: { condition: service_healthy } }
ports:
web:
build: { context: ., dockerfile: apps/web/Dockerfile }
environment: { VITE_API_BASE_URL: http://localhost:8080/api }
ports:
volumes: { pgdata: {} }
Use the same image in staging and production, promoted by digest. Only configuration and secrets differ. Run database migrations as a controlled release job, not simultaneously in every API replica. Terminate TLS at the ingress and preserve WebSocket upgrade headers.
15. GitLab CI/CD
stages:
default:
image: node:22-alpine
cache: { key: "$CI_COMMIT_REF_SLUG", paths:
before_script:
– corepack enable
– pnpm config set store-dir .pnpm-store
– pnpm install –frozen-lockfile
lint:
stage: validate
script:
unit_test:
stage: test
script:
coverage: '/All files
artifacts: { when: always, reports: { junit: reports/junit.xml } }
build_apps:
stage: build
script:
artifacts: { paths:
container_images:
stage: build
image: docker:27
services:
before_script:
script:
– docker build -f apps/api/Dockerfile -t $CI_REGISTRY_IMAGE/api:$CI_COMMIT_SHA .
– docker build -f apps/web/Dockerfile -t $CI_REGISTRY_IMAGE/web:$CI_COMMIT_SHA .
– docker push $CI_REGISTRY_IMAGE/api:$CI_COMMIT_SHA
– docker push $CI_REGISTRY_IMAGE/web:$CI_COMMIT_SHA
deploy_staging:
stage: deploy
environment: { name: staging, url: https://staging.example.com }
script:
rules:
deploy_production:
stage: deploy
environment: { name: production, url: https://example.com }
script:
rules:
when: manual
allow_failure: false
Add dependency/container scanning, secret detection, an end-to-end staging smoke test, and protected production environments. A strong pipeline promotes the previously tested digest; it does not rebuild source during production deployment.
16. Setup, run, and deploy
Prerequisites
- Node.js 22 and Corepack/pnpm
- Docker with Compose
- PostgreSQL 17 when running without containers
- A GitLab project with registry and protected environment variables
Local development
- Copy .env.example to .env.local and use development-only values.
- Run corepack enable and pnpm install –frozen-lockfile.
- Start PostgreSQL with docker compose up -d db.
- Apply migrations: pnpm –filter @supersite/api db:migrate.
- Seed a local admin and configuration: pnpm –filter @supersite/api db:seed.
- Start both apps: pnpm dev.
- Open the web URL, publish a test configuration, and confirm a second tab updates immediately.
Test and build
pnpm lint
pnpm typecheck
pnpm test
pnpm test:e2e
pnpm build
docker compose build
docker compose up –wait
Staging release
- Merge through a reviewed merge request.
- CI validates, tests, builds, scans, and pushes images tagged by commit SHA.
- The default branch deploys the digest to staging.
- Run migrations once, then readiness and smoke tests.
- Exercise config edit/publish, banners, WebSocket propagation, and rollback.
Production release
- Create a semantic version tag from the tested commit.
- Approve the protected manual deployment.
- Run backward-compatible migrations.
- Roll out API and web images by immutable digest.
- Watch error rate, latency, readiness, socket connections, and configuration publication metrics.
- Rollback the application digest if unhealthy; rollback configuration by publishing a prior version as a new version.
17. Prototype/example code vs. production hardening
Important. The snippets in this guide are coherent reference implementations, not a copy-paste production system. They demonstrate contracts and control flow; production work must close the items below.
| Example/prototype | Production hardening |
| In-memory socket hub | Cross-instance broker, delivery metrics, heartbeat, connection limits, graceful shutdown. |
| Basic role middleware | Identity-provider integration, fine-grained permissions, session revocation, CSRF, secure cookies. |
| JSONB configuration | Schema migrations, immutable history, transactional publication, backups, retention, disaster recovery. |
| Simple deep merge | Property allowlist, prototype-pollution defense, explicit array/null semantics, schema versioning. |
| Raw gradient/color inputs | Strict parsing, contrast validation, branded palette option, safe-link policy. |
| Basic CI example | Locked images, SBOM/signing, SAST, dependency and container scans, provenance, deploy freeze controls. |
| Single-region deployment | Capacity model, autoscaling, connection draining, broker resiliency, regional failure strategy. |
| Console-level observability | Structured logs with request IDs, RED metrics, traces, dashboards, alerts, SLOs and runbooks. |
| Functional accessibility | Automated axe plus manual screen-reader, keyboard, zoom, contrast, and reduced-motion testing. |
| Basic admin preview | Isolated preview origin/frame, CSP, explicit draft labels, conflict UX, approval workflow where needed. |
Production acceptance checklist
- Every accepted configuration validates against an explicit versioned schema.
- Publication and audit write are atomic; rollback creates a new auditable version.
- Route identifiers are shared and tested; overrides cannot orphan silently.
- Public rendering survives API/WebSocket outages using last-known valid configuration.
- Admin writes have authentication, authorization, CSRF protection, rate limiting, and audit events.
- WebSocket reconnect catches version gaps and works through the production proxy.
- Motion respects reduced-motion settings; all controls are keyboard and screen-reader usable.
- Images, fonts, and video behavior are measured under realistic network and device constraints.
- Staging uses the production image and deployment topology.
- Backups and restore procedures are tested, not merely documented.
18. Recommended first implementation sequence
- Land shared schema, compiled defaults, and route resolver with unit tests.
- Refactor MoviesScenesGrid and SearchResultsPage onto MediaGrid.
- Add PosterContainer and PlayerContainer settings with accessibility-safe rendering.
- Add AdminConfigContext using mocked published config; implement preview.
- Build persistent API, migrations, optimistic draft saves, publication, audit trail.
- Add WebSocket publication and reconnect recovery.
- Build banner scheduler and role-restricted admin workflows.
- Add component/API/E2E/accessibility tests.
- Containerize, then establish GitLab staging promotion and production approval.
- Instrument SLOs, practice rollback, and close the hardening checklist.
End of SUPER SITE Setup, Architecture & Production Blueprint.