Share Wings: Building a Multi-Brand Aircraft Dealership Platform
Four products - marketing site, AI sales concierge, CRM, and customer portal - for a Swiss aircraft dealership, shipped as one Next.js app on one Postgres. A study in breadth without sprawl.
Share Wings (sharewings.ch) is a single Next.js application that does the job of four separate products for a Swiss multi-brand aircraft dealership (Diamond Aircraft, Blackwing, and Blackshape - Gabriel + Prime). This is the long-form "how I built it" write-up: schemas, ADRs, file paths, and code excerpts taken straight from the repo.
TL;DR
One app, four products:
- A public marketing site - multilingual (DE/EN, FR/IT structurally ready), headless-CMS-driven, SEO-complete, with a per-model aircraft configurator.
- An AI sales concierge - an agentic RAG assistant that qualifies leads, answers spec questions from grounded sources, books discovery flights, and hands off to humans, with guardrails and an eval harness.
- An internal CRM - a full replacement for HubSpot: prospect pool, deal kanban, email composer with reply tracking, sequences, reporting, and role-based access.
- A post-sale customer portal - magic-link auth, build milestones, e-signature, document vault, configuration sharing, and a referral program.
The whole thing ships as one Vercel deployment with one Postgres database. No separate worker fleet, no microservice sprawl, no second design system. The interesting engineering is in the constraints I chose up front and held to - they're what kept a project this broad from collapsing under its own surface area.
The stack, and why each piece is there
| Layer | Choice | Why this and not the obvious alternative |
|---|---|---|
| Framework | Next.js 16 (App Router), TypeScript strict | Server Components by default means most of the site ships zero client JS. Server Actions give a typed mutation surface without hand-rolling API routes. |
| UI | Porsche Design System v4 (web components) | A premium, accessibility-audited component set that matches the aspirational tone of aircraft sales. Migrated from shadcn mid-project (ADR-0007). |
| CMS | Storyblok (headless) | Non-technical editors get a visual editor with live preview; developers get typed fetchers. Content velocity without code deploys. |
| DB / Auth / Storage | Supabase (Postgres + Auth + Storage) | One managed vendor for the three boring-but-critical primitives. Local stack via supabase start mirrors production. |
| ORM / migrations | Drizzle ORM + drizzle-kit | Schema-as-TypeScript, plain .sql migrations in git, native pgvector, tiny serverless cold-start. Chosen over Prisma and the Supabase JS client (ADR-0001). |
| Vector search | pgvector | Keeps embeddings in the same Postgres as everything else - no separate vector DB to operate or sync. |
| LLM | Anthropic Claude | Opus for the concierge and synthesis, Sonnet for background extraction, Haiku for reranking and cheap classification. One vendor, three price/latency tiers. |
| Embeddings | Voyage (voyage-3-large, 1024-dim) | Anthropic doesn't ship an embedding API, so the embedding model is decoupled from the chat model. |
| Background jobs | Inngest | Durable, debounced, cron-capable workflows that run inside the same Next app via a serve handler - no separate worker process (ADR-0005). |
| Rate limiting | Upstash Redis | HTTP-only sliding-window counters that don't contend with the primary DB on every form submit (ADR-0004). |
| Resend + React Email | Transactional send with React templates; local dev routes through an Inbucket SMTP relay. | |
| Transcription | AssemblyAI | Speaker-diarized call transcription for the call-intelligence pipeline. |
| E-signature | Yousign | Embedded signing for portal contracts, with a manual-mode fallback when the API key is unset. |
| Observability | Sentry + PostHog + OpenTelemetry | Crash visibility, consent-gated analytics, request tracing - all no-op without keys so local/CI stay simple. |
| Hosting | Vercel | First-class Next.js host; the Inngest serve route and cron-style endpoints live in the same deployment. |
The throughline: minimize the number of things that can independently break. Every vendor had to earn its place against "could Postgres or the existing app do this?" Upstash, Inngest, Voyage, and AssemblyAI each cleared that bar with a specific reason recorded in an ADR.
Architecture at a glance
Visitors (DE / EN, FR + IT ready)
│
┌──────────────────────────────┼──────────────────────────────┐
▼ ▼ ▼
┌──────────────┐ ┌──────────────────┐ ┌──────────────┐
│ Marketing │ │ AI Sales │ │ Customer │
│ site │ │ Concierge │ │ Portal │
│ app/(public) │ │ /api/concierge │ │ app/(portal) │
└──────┬───────┘ └────────┬─────────┘ └──────┬───────┘
│ │ │
│ ┌────────┴────────┐ │
│ ▼ ▼ │
│ ┌───────────────┐ ┌───────────┐ │
│ │ lib/ai │ │ Internal │◀────────────┘
│ │ Anthropic │ │ CRM │
│ │ prompts/tools │ │ app/app │
│ └───────┬───────┘ └─────┬─────┘
▼ ▼ ▼
┌──────────────┐ ┌───────────────────────────────┐
│ lib/storyblok│ │ lib/db │
│ headless CMS │ │ Drizzle + typed repos │
└──────────────┘ │ (Postgres + pgvector) │
└───────────────┬───────────────┘
▼
┌───────────────────────────────┐
│ Supabase: Postgres·Auth·Store │
└───────────────────────────────┘
▲
lib/inngest ────┘ durable jobs (synthesis, NBA, calls)
A few rules hold this together and are enforced in code review and lint config:
- The repo layer is the only place that issues SQL. Feature code calls
functions in
lib/db/repos/; it never touches the Drizzle client directly. - All Anthropic calls go through
lib/ai/client.ts. No feature instantiates the SDK. - Every external input is parsed by Zod before it reaches a repo.
- Server Components by default;
"use client"only for genuine interactivity.
How it was built, phase by phase
The project was built as a sequence of phases, each a coherent slice that left
main deployable. The convention was strict: one build-plan bullet = one PR,
Conventional Commits, and an ADR whenever a decision was hard to reverse.
Phase 0 - Scaffold and guardrails
Before any feature code: TypeScript strict mode, ESLint (with custom rules that
block importing the old components/ui/*, lucide-react, and radix-ui),
Prettier, Husky pre-commit running lint-staged + tsc --noEmit, Vitest +
Playwright wiring, and CI.
The single most valuable early decision was Zod-validated, server-only env:
// lib/env.ts (excerpt)
import "server-only";
import { z } from "zod";
const serverSchema = z.object({
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
DATABASE_URL: z.string().url(),
ANTHROPIC_API_KEY: z.string().min(1),
ANTHROPIC_MODEL: z.string().default("claude-opus-4-7"),
VOYAGE_API_KEY: z.string().min(1),
// …Storyblok, Resend, Upstash, Inngest, Yousign, AssemblyAI, Sentry, PostHog…
});
The app refuses to boot if a required variable is missing or malformed - failures
surface at startup, not at 2 a.m. in a Server Action. Optional vars are
.optional() so contributors and CI run the whole thing with zero third-party
accounts; the corresponding subsystems no-op when their keys are unset.
Trade-off: strict mode + a pre-commit typecheck makes the first commit of any feature slower. The payoff is that the last commit before a deploy is almost never a surprise.
Phase 1 - Data model and the repository pattern
The schema lives in lib/db/schema/ (one module per aggregate, barrel-exported),
Drizzle is the source of truth, and pnpm db:generate emits plain SQL into
drizzle/migrations/ - there are 22 of them now, each reviewable as a diff.
The pattern that paid off across every later phase: every repo function takes the Drizzle client as its first argument.
// lib/db/repos/deals.ts (excerpt) — stage transitions are atomic + audited
export async function transitionDealStage(
db: DbClient,
id: string,
toStage: Deal["stage"],
changedBy: string | null,
note?: string,
): Promise<Deal | null> {
return db.transaction(async (tx) => {
const [current] = await tx
.select({ stage: deals.stage })
.from(deals).where(eq(deals.id, id)).limit(1);
if (!current) return null;
if (current.stage === toStage) { /* …no-op short-circuit… */ }
const [updated] = await tx
.update(deals)
.set({ stage: toStage, updatedAt: sql`now()` })
.where(eq(deals.id, id)).returning();
await tx.insert(dealStageHistory).values({
dealId: id, fromStage: current.stage, toStage, changedBy, note,
});
return updated ?? null;
});
}
Why (db, ...args) everywhere? Because it makes integration tests run against a
real Postgres without smuggling module-level singletons. Server callers pass the
lib/db/client.ts singleton; tests pass an instance bound to TEST_DATABASE_URL.
When that's unset, every DB-backed test skips instead of failing, so the suite
stays green on a laptop with no database.
A few data-model decisions worth calling out:
- Append-only logs.
interactions,consents, anddeal_stage_historyexpose onlyrecord*/transition*functions - noupdate*/delete*. Current state is derived (latest row per key). This guarantees an audit trail by construction. - Soft-delete only on
prospects. GDPR/FADP "right to be forgotten" needs to hide a person without breaking FK references. A partial unique index lets a deleted person's email be reclaimed by a new lead. - Money as integer CHF. Swiss list prices are whole francs; integers dodge the
floating-point gotchas of
numericarithmetic in JS. - The embedding column is
vector(1024), and the history shows why migrations matter. Phase 1 shipped it atvector(1536)to match the OpenAI /voyage-3default. When I locked the provider to Voyage'svoyage-3-largeat 1024-dim, migration0002ranALTER … SET DATA TYPE vector(1024). A singleEMBEDDING_DIMENSIONS = 1024constant is now the source of truth, and the Voyage client throws if the API ever returns a vector of a different length - the column, the index, and the runtime can't silently drift apart.
Phase 2 - Storyblok integration
Two flavours of components: content types (page, brand,
aircraft_model_page, article, …) that own a URL, and nestable bloks
(hero, spec_table, gallery, cta_band, faq, …) placed inside a content
type's body.
The schema is defined once as JSON in storyblok/components/; the runtime
contract - Zod + TS types - lives in lib/storyblok/types.ts. Every fetcher
parses responses through those Zod schemas, so a renamed field in the CMS throws
a loud runtime error instead of rendering undefined. The webhook receiver
(/api/storyblok/revalidate) calls revalidateTag(...) on publish, so editors see
changes within seconds without a rebuild.
Phase 3 - The public marketing site
A hybrid routing model (ADR-0002): explicit routes for high-traffic surfaces
(/, /aircraft/[brand]/[model], /journal, …) that hand-compose their layout,
plus a [...slug] catch-all that delegates anything else to Storyblok. The
model detail page can't come from a linear blok stack - it interleaves CMS content
with hard-coded structure (key-specs trio, comparison hook, owner-stories
carousel, dual CTAs).
Full SEO shipped here: per-route generateMetadata, JSON-LD (Organization,
Breadcrumb, Product with AggregateOffer), dynamic OG images via @vercel/og,
sitemap, robots, and Lighthouse CI on every PR.
Phase 4 - Forms and the configurator
Public forms got IP rate limiting via Upstash (ADR-0004), with an in-memory
fallback for local dev. The per-model aircraft configurator is a structured
option-picker (3D was explicitly scoped out - buyers want price clarity first),
backed by aircraft_options, aircraft_packages, configurations,
configuration_revisions, and configuration_quotes.
The compatibility engine is a deterministic cascade over mutually-exclusive groups,
transitive requires, and symmetric excludes; pricing is a pure function re-run
server-side on every save. Non-verified prices flow a confidence flag all the way
to the UI, so the same code renders honestly whether the data is OEM-verified or a
placeholder seed.
Phase 5 / 6 - The AI Sales Concierge
This is the centerpiece - an agentic RAG pipeline behind a Server-Sent-Events endpoint:
user message → POST /api/concierge (SSE)
→ persist turn (conversations + messages, append-only)
→ retrieve(): vector search + Postgres FTS → Reciprocal Rank Fusion → Haiku rerank
→ load memory: rolling summary + prospect_synthesis
→ tool-use loop: Opus stream → tool_use → handler → tool_result (cap 8 iters)
→ telemetry → llm_calls
→ stream meta / tool_start / tool_result / delta / done
Everything funnels through one SDK wrapper that centralizes model selection, retry with jittered backoff, tracing spans, and per-call cost telemetry:
// lib/ai/client.ts (excerpt)
export async function createMessage(opts: CreateMessageOptions) {
return withSpan("anthropic.messages.create", { "ai.feature": feature, "ai.model": model },
async (span) => {
const message = await withRetry(() => anthropic.messages.create({ ...body, model }));
// …compute usage + cost, set span attrs (NO prompt content — spans are PII-visible)…
await writeTelemetry({ db, feature, model, prospectId, usage, latencyMs, metadata });
return { message, usage, costUsd, latencyMs };
});
}
Design choices that matter here:
- Tools are one file each -
{ definition, inputSchema (Zod), handler }- registered inlib/ai/tools/index.ts:lookup_aircraft_spec,run_ownership_calculator,check_delivery_availability,save_qualified_lead,book_discovery_flight,request_factory_tour, andescalate_to_human. Adding a tool is a drop-in; CI evals fail loudly if you don't account for it. - Prompts are versioned. Each module exports
{ id, version, system, build() }. The static body goes insystem(so Anthropic's prompt cache stays warm); dynamic per-turn context goes throughbuild(). Version numbers are never reused, so historicalllm_callsrows always map to the exact revision that produced them. - Two-tier memory. Short-term is a token-budgeted history with a Haiku-written
rolling summary; long-term is
prospect_synthesisfolded into the system prompt. Both are tagged "DATA, not instructions." - Guardrails in three layers. A Haiku prompt-injection classifier (score ≥7 short-circuits with a localized refusal but still records the message), an adversarial-chunk shape detector, and PII redaction before anything is logged. The classifier fails open - if Haiku is down, the chat still works.
- Cost accounting is structural. Every Anthropic call writes a row to
llm_callswith computed USD cost at write time, so historical rows stay correct after any price change.
And it's evaluated, not vibes-tested. pnpm eval:concierge runs 60 cases
across 8 categories with structured assertions (must_call_tool, must_cite,
must_refuse, must_be_in_language, …). CI runs a deterministic smoke mode
(scripted replies, no token spend) and gates merges on pass-rate regression; a
live mode runs the real loop against the API before merge.
Phase 8 - The CRM (HubSpot replacement)
A full sales workspace at /app, behind Supabase magic-link / Google auth with
three roles (viewer/sales/admin): a filterable prospect pool with bulk assign
- CSV export and per-user saved views; a drag-and-drop deal kanban whose moves are
atomic transitions (drop into "Lost" requires a
lost_reason- a DB check constraint, not a UI suggestion); an inline email composer that sends via Resend and logs anemail_sentinteraction; inbound email via plus-addressedReply-Totokens through an HMAC-verified webhook; nurture sequences; and a Reports page (funnel, conversion by source, pipeline value, AI spend).
Row-level security is real and tested: a 13-case lib/db/rls.test.ts runs queries
through the authenticated PostgREST role with seeded JWTs to prove
admin/sales/viewer visibility, deal-ownership writes, and saved-view scoping.
Phase 9 - Background intelligence (Inngest)
The durable-jobs layer (ADR-0005). Functions live in lib/jobs/, the serve handler
is /api/inngest, and inngest.send() calls are fire-and-forget helpers that
never break a user-facing action. What runs:
- Buyer synthesis -
prospect/activity.recordeddebounced 90s per prospect, so a five-message chat burst coalesces into one Opus run that regenerates the long-termprospect_synthesis. - Nightly NBA agent - a
TZ=Europe/Zurich 0 6 * * *cron fans out one "next-best-action" evaluation per active deal; Sonnet proposes outreach, dedup'd against pending suggestions <24h old. - Call intelligence - upload audio → Supabase Storage → AssemblyAI
(speaker-labeled) → Sonnet extracts objections, action items, and a drafted
follow-up email. Each poll is its own
step.run, so the dashboard shows progress and a step that already succeeded isn't re-executed on retry.
The key win: no separate worker process. Inngest calls back into the same Next deployment, with built-in debouncing, concurrency keys, step checkpointing, and retries. The trade-off I accepted is vendor lock-in on the step semantics.
Phase 10 - Customer portal
Post-deposit area at /portal/*: magic-link auth scoped to its own route group, RLS
that proves customer A can't see customer B's data (current_portal_prospect_id()
- a
SECURITY DEFINERportal_can_read_deal()helper), build-milestone notifications, configuration sharing via signed tokens, referrals, and e-signature with a manual fallback: withYOUSIGN_API_KEYunset the request sits inpendingand sales marks it signed offline; set the key and the same flow becomes fully embedded - no code change, both paths produce the same audit trail.
Phase 11 - Observability and go-live
Sentry (with a tested PII scrubber on every beforeSend), PostHog (consent-gated -
no profiling-consent row means no event, distinct-id is the prospect UUID, never
PII), and OpenTelemetry tracing exported through Sentry's tracer provider. Spans
wrap every Anthropic call, every retrieval stage, and every tool invocation - with
a hard rule that no prompt content or PII goes into span attributes.
Cross-cutting patterns I'm proud of
1. The repo layer as the only SQL boundary. Route handlers, Server Actions, components, scripts, and tests all call named repo functions. This is why a junior contributor can't accidentally write an N+1 query in a component, and why the audit-trail invariants can't be bypassed.
2. Zod at every boundary, mirrored against Drizzle. lib/db/schema-validators/
holds Zod schemas that mirror the tables. They're not generated - you update both
when you add a field - which forces a conscious decision about what's allowed in
from the outside vs. what the DB stores.
3. "No keys" is a first-class runtime state. Upstash, Sentry, PostHog, Yousign, AssemblyAI, and the dev SMTP relay all degrade gracefully to a no-op or a local fallback. The entire platform runs end-to-end with nothing but a local Supabase and an Anthropic key.
4. One design system, enforced. After migrating to Porsche Design System v4,
ESLint blocks the old primitives and lucide-react. A strict unitary typography
scale and the PDS grid are why the site looks coherent across four products built
over many phases.
Decisions I'd defend (and their costs)
The repo keeps a real ADR log. The ones that shaped the most code:
- Drizzle over Prisma / Supabase-JS (ADR-0001). Won on bundle size, cold-start, native pgvector, and reviewable SQL. Cost: two ways to reach Postgres.
- Hybrid routing (ADR-0002). Explicit routes for SEO-critical pages, catch-all
for editorial freedom. Cost: model slugs duplicated between Storyblok and a
BRAND_NAVconstant - fine at six models, with a plan to generate it past ~15. - Upstash for rate limiting (ADR-0004). Form submits don't contend with prospect/interaction writes. Cost: one more vendor and token to rotate.
- Inngest for durable jobs (ADR-0005). No worker fleet; debounce + cron + checkpointing out of the box. Cost: step-semantics lock-in.
- PDS v4 as the design system (ADR-0007), superseding the earlier "shadcn-only"
rule (ADR-0006). A mid-project pivot of the entire primitive layer. Cost: a
documented migration map, an icon map, and a CSP that whitelists
cdn.ui.porsche.com. The most expensive decision in the project - and a case study in when a rewrite is worth it.
The discipline I'm happiest about isn't any single choice - it's that each one is written down with its negative consequences and follow-ups, so the next person (or the next me) inherits the reasoning, not just the result.
What this project demonstrates
- Breadth without sprawl. Four products, eleven vendors, one deployment, one database - held together by a handful of hard invariants (repo-only SQL, one AI client, Zod at every edge, one design system) that are enforced, not just documented.
- AI built like infrastructure. The concierge isn't a wrapper around a chat endpoint - it has grounded retrieval, versioned prompts, typed tools, layered guardrails, structural cost accounting, and an eval suite that gates CI.
- Decisions with receipts. Every hard-to-reverse choice is an ADR with its costs and follow-ups spelled out - including a mid-flight rewrite of the entire UI primitive layer.
Appendix: numbers and surface area
- 22 Drizzle migrations, all generated except one documented bootstrap edit.
- ~50 typed repository modules; ~25 Zod validator modules mirroring them.
- 7 concierge tools; 60 concierge eval cases across 8 categories.
- 5 Inngest functions + a configurator-revisit detector.
- 2 launch locales (DE/EN), FR + IT structurally wired.
- 3 brands, 5 seeded aircraft models, a per-model configurator.
- 9 CI / automation workflows.