All posts
EngineeringJune 2, 202615 min read

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:

  1. A public marketing site - multilingual (DE/EN, FR/IT structurally ready), headless-CMS-driven, SEO-complete, with a per-model aircraft configurator.
  2. 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.
  3. An internal CRM - a full replacement for HubSpot: prospect pool, deal kanban, email composer with reply tracking, sequences, reporting, and role-based access.
  4. 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

LayerChoiceWhy this and not the obvious alternative
FrameworkNext.js 16 (App Router), TypeScript strictServer Components by default means most of the site ships zero client JS. Server Actions give a typed mutation surface without hand-rolling API routes.
UIPorsche 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).
CMSStoryblok (headless)Non-technical editors get a visual editor with live preview; developers get typed fetchers. Content velocity without code deploys.
DB / Auth / StorageSupabase (Postgres + Auth + Storage)One managed vendor for the three boring-but-critical primitives. Local stack via supabase start mirrors production.
ORM / migrationsDrizzle ORM + drizzle-kitSchema-as-TypeScript, plain .sql migrations in git, native pgvector, tiny serverless cold-start. Chosen over Prisma and the Supabase JS client (ADR-0001).
Vector searchpgvectorKeeps embeddings in the same Postgres as everything else - no separate vector DB to operate or sync.
LLMAnthropic ClaudeOpus for the concierge and synthesis, Sonnet for background extraction, Haiku for reranking and cheap classification. One vendor, three price/latency tiers.
EmbeddingsVoyage (voyage-3-large, 1024-dim)Anthropic doesn't ship an embedding API, so the embedding model is decoupled from the chat model.
Background jobsInngestDurable, debounced, cron-capable workflows that run inside the same Next app via a serve handler - no separate worker process (ADR-0005).
Rate limitingUpstash RedisHTTP-only sliding-window counters that don't contend with the primary DB on every form submit (ADR-0004).
EmailResend + React EmailTransactional send with React templates; local dev routes through an Inbucket SMTP relay.
TranscriptionAssemblyAISpeaker-diarized call transcription for the call-intelligence pipeline.
E-signatureYousignEmbedded signing for portal contracts, with a manual-mode fallback when the API key is unset.
ObservabilitySentry + PostHog + OpenTelemetryCrash visibility, consent-gated analytics, request tracing - all no-op without keys so local/CI stay simple.
HostingVercelFirst-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, and deal_stage_history expose only record*/transition* functions - no update*/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 numeric arithmetic in JS.
  • The embedding column is vector(1024), and the history shows why migrations matter. Phase 1 shipped it at vector(1536) to match the OpenAI / voyage-3 default. When I locked the provider to Voyage's voyage-3-large at 1024-dim, migration 0002 ran ALTER … SET DATA TYPE vector(1024). A single EMBEDDING_DIMENSIONS = 1024 constant 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 in lib/ai/tools/index.ts: lookup_aircraft_spec, run_ownership_calculator, check_delivery_availability, save_qualified_lead, book_discovery_flight, request_factory_tour, and escalate_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 in system (so Anthropic's prompt cache stays warm); dynamic per-turn context goes through build(). Version numbers are never reused, so historical llm_calls rows 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_synthesis folded 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_calls with 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 an email_sent interaction; inbound email via plus-addressed Reply-To tokens 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.recorded debounced 90s per prospect, so a five-message chat burst coalesces into one Opus run that regenerates the long-term prospect_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 DEFINER portal_can_read_deal() helper), build-milestone notifications, configuration sharing via signed tokens, referrals, and e-signature with a manual fallback: with YOUSIGN_API_KEY unset the request sits in pending and 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_NAV constant - 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

  1. 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.
  2. 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.
  3. 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.

Ask about Julian Walder

Grounded in his real work

Hi! I'm Julian Walder's assistant. Ask me anything about his work, projects, or background in AI.