THE VEIL

Next.js 15 | React Native (Expo) | PostgreSQL + RLS | Stripe Connect | Deno Edge Functions

Founder & Lead Systems Architect — solo build, April 2026 → Present

Live Product — iOS, Android & Web

A wedding platform in two halves that share one database.

The Veil is a consumer planning app for couples and their guests, a vendor CRM and marketplace for the businesses they hire, and a single Postgres instance underneath both — where the permission model, the pricing authority and the audit trail actually live. I designed, built, secured and shipped all three surfaces solo: schema, API, edge functions, mobile client, web client, payments, moderation tooling and the go-to-market machinery.

The interesting engineering here is not the feature list. It is that a guest, a co-planner, a day-of coordinator, a vendor and a staff member all read from the same tables, and the database — not the client — decides what each of them is allowed to see.

300KLines of Code
550SQL Migrations
75Edge Functions
33App Screens
3Shipped Surfaces
1Engineer

SYSTEM ARCHITECTURE

01. Two Products, One Source of Truth

The platform deploys as two independent builds out of one repository. The consumer app is an Expo / React Native codebase that compiles to iOS, Android and a web bundle from the same source. The marketing site and the vendor portal are a separate Next.js 15 application. They are deliberately decoupled: different frameworks, different build steps, and a marketing typo should never trigger a React Native bundle rebuild.

Both talk to the same PostgreSQL 17 instance through an anonymous key, which means row-level security is the only real boundary — there is no trusted middle tier to hide behind. Anything that must not be forgeable (prices, entitlements, wedding dates, role checks) is resolved server-side inside Postgres or a Deno edge function.

        ┌──────────────────────────────────┐
        │   PostgreSQL 17  ·  us-east-1    │
        │   auth · storage · RLS · edge    │
        └────────────────┬─────────────────┘
                         │  anon key + RLS
         ┌───────────────┴────────────────┐
         │                                │
┌────────▼─────────┐          ┌───────────▼──────────┐
│  CONSUMER APP    │          │  NEXT.JS 15          │
│  Expo / RN + Web │          │                      │
│  iOS · Android   │          │  (marketing)  public │
│  app.theveil…    │          │  /portal  vendor CRM │
│                  │          │  /admin   staff ops  │
└──────────────────┘          └──────────────────────┘

02. The Permission Model Is Two Predicates, Not a Roles Table

Five distinct human roles touch a single wedding — host, secondary host, co-planner, day-of coordinator and plain guest — and none of them exist as a row in a roles table. Access is derived by two SQL predicates that every shared table is guarded with, which means a role can never drift out of sync with the permission it implies.

That produces two enforcement tiers. Tier A — money (registry, cash funds, Stripe Connect accounts, payment links, shipping address) is restricted to the host and an active secondary host; a co-planner or coordinator is denied every write. Tier B — planning (guest list, itinerary, seating, notes, vows, vendor bookings, activity log) is open to collaborators.

Module-level delegation is enforced in the database, not the UI. Before that migration landed, a co-planner granted only the itinerary could still write the guest list and the couple's vows through the API — the toggle existed, but only the client respected it. The database function now mirrors the client's permission check exactly, and an absent key is a denial. Self-escalation is blocked twice over: a collaborator without guest-list access cannot see their own row to edit it, and a trigger raises if they try.

RoleMoneyPlanning
Hostyesyes
Secondary host (partner)yesyes
Fiancé-group guestnoyes
Co-plannernoscoped
Day-of coordinatornoscoped
Plain guestnono

Seven permission keys map module grants to the exact tables they gate. A partial unique index allows exactly one day-of coordinator per wedding.

03. Guests Never Touch Tables Directly

A plain guest reads zero rows of the guest list — including their own. Every guest action instead runs through a SECURITY DEFINER function scoped to the caller's claimed seat: RSVP status, dietary notes, plus-ones, party members, table assignment. The guard lives in the function signature, so there is no policy surface for a crafted request to probe.

The same pattern secures partner linking. The secondary-host table has no UPDATE policy at all; every legitimate transition — invite, accept, disconnect — runs through a definer function. Restoring a "convenient" policy there would silently reopen a privilege-escalation path, so the reasoning is written into the migration header rather than left in someone's memory.

The product payoff is the Guest Passport: a guest scans a QR roster code or opens an invite link and gets the itinerary, dress code, their seating assignment, the registry and a message thread — with no account, no app install, and no access to a single byte of the couple's private planning data.

The Veil consumer app home dashboard

04. Two Storage Models, Side by Side — On Purpose

The couple's private build — every wedding profile, the budget, style attributes, registry state, DIY progress — lives as a single jsonb document mirrored to device storage. It is one round trip, it works offline, and nothing about it needs a join. That is the right shape for data exactly one person edits.

Everything collaborative is fully relational: guest list, itinerary events, seating tables, table assignments, registry items, vendor bookings and roughly twenty more, each with its own RLS policies. That is the right shape for data a second person has to be allowed — and denied — access to.

The consequence is the part worth knowing, and it is documented in the architecture notes precisely because it bites: wiping the relational tables does not wipe the wedding. The device re-uploads its document on next open, so any "delete my data" path has to address both models or it will appear to have silently failed.

Drag-and-drop floor planner with seating assignment

MONEY, AI & MARKETPLACE

05. Payments: The Client Never Sends a Price

Five payment flows run through Stripe and RevenueCat: consumer subscriptions on web, the same subscriptions through the app stores, guest contributions to cash funds, couple-to-vendor booking payments, and vendor Pro subscriptions. Platform fees are charged at the edge — 1.5% on cash funds (waived on Plus) and 3% on vendor bookings (waived on Pro).

The client sends a plan name, never an amount. The edge function looks up the price, because a client that could send an amount could send $1. Three runtimes cannot share a module, so each display constant carries a comment naming the edge-function constant it must match — the function charges the card, so the function wins.

The Stripe webhook is the single writer of booking rows on payment, keyed on the checkout session id and idempotent against Stripe's at-least-once delivery. That one row then feeds both the couple's budget ledger and the day-of vendor sheet, so the two physically cannot disagree about who is showing up.

FlowFee
Couple subscribes to Plus—
Guest contributes to a cash fund1.5% · 0% on Plus
Couple pays a vendor3% · 0% on Pro
Vendor subscribes to Pro—

Entitlement for the one-time "whole wedding" plan is a hard expiry column set to the wedding date plus 60 days — and the wedding date is resolved server-side, never taken from the request, because a client-supplied date is a date a client can set to 2099.

06. The AI DJ — Three Signals, Thirteen Briefs

The music engine takes three inputs and the entire job is keeping them distinct. The questionnaire is explicit instruction, so it wins as a hard constraint. Spotify supplies taste texture — and the genre tags inferred for a couple's artists turn out to be far more useful than the artist names, because nobody wants their most-played track playing at their parents' dance. The moment supplies function.

"Processional" is a job, not a genre: it has to carry a person up an aisle at walking pace, so its brief asks for a singable melody at roughly 60–80 BPM with an unmistakable downbeat. All thirteen sections of the night carry their own sonic brief. Six of them used to fall through to "curate appropriate wedding music", which is no direction at all — cocktail hour and the last dance were being handed the same prompt.

The earlier version also requested a Spotify scope it never actually held, so the taste lookup silently 403'd and fell through to the literal string "Popular Artists". Fixing that was less about prompting than about noticing a failure that looked like a working feature.

Live itinerary with AI-generated events

07. A Checklist That Is a Dependency Graph

The planning checklist is generated from the couple's date, budget and location as a set of tasks carrying day offsets relative to the wedding, a category, an estimated cost, and an index pointing at the task each one depends on. Booking a photographer depends on booking a venue; the graph knows it.

Because deadlines are stored as offsets rather than dates, moving the wedding re-derives every deadline instead of invalidating the list — the app detects the drift and offers to resync. Tasks also auto-complete from real state elsewhere in the system: confirm a budget and "Establish Initial Budget" closes itself, set a guest count and "Create Guest List" closes itself.

The generator falls back to a hand-authored template when the model is unavailable, so a failed AI call degrades into a perfectly usable default plan rather than an empty screen. It ships on the free tier deliberately: it is the feature that proves the product works before anyone is asked to pay.

AI-generated live checklist with auto-completed tasks

08. The Cold-Start Engine

A marketplace with no vendors is worthless to a couple, and a marketplace with no couples is worthless to a vendor. The incumbents solved that over a decade with a sales team. The engineering answer is to refuse to have an empty directory on day one: real local businesses sourced from Google Places render as storefronts before any of them have an account.

When a couple enquires about an unclaimed business, one call materialises a real profile row with a null owner and another finds the vendor and reaches out on the couple's behalf. The outreach is therefore never cold — it is "a couple asked about you, by name, this week." The vendor claims the storefront through a verification flow and the enquiry is already sitting in their inbox.

Two decisions in there I would defend anywhere. The system refuses to invent an email address: it fetches the listing's own site and looks for an address the business actually published, and returns not found rather than guessing at info@. And live sending is gated behind an exact opt-in secret — unset, misspelled or forgotten all fail toward never emailing a real business, redirecting to an internal address instead.

 Couple browses a category
        │
        ▼
 Sees a REAL local business
 with no Veil account yet
        │
        ├─► create_unclaimed_business()
        │     materialises the storefront
        │
        └─► inquire-unclaimed-vendor
              finds a published address,
              reaches out on their behalf
        │
        ▼
 Vendor claims the storefront
 (request → verify)
        │
        ▼
 Enquiry is waiting in the inbox
 Free: 3% on platform bookings
 Pro:  0% + verified badge

THE BUSINESS SURFACES

09. Vendor Portal, Staff Console & Moderation

The Next.js side ships two authenticated products beyond the public site. The vendor portal is a working CRM: storefront editor, enquiry pipeline with a per-event day sheet, threaded messaging, availability calendar, billing, team seats and a social profile. The staff console covers acquisition, revenue, health, moderation, audit, outreach campaigns and the cold-start funnel.

Staff access is ranked sales < ops < owner and checked in Postgres, never in the browser. Sales sees aggregates and never sees anything naming a couple. The staff table itself has RLS enabled with no policies at all and grants revoked: a staff list that staff can edit from a web page is a staff list that can be escalated, so adding someone is a deliberate act at the database. The page reads it and cannot write to it.

Moderation removes a post by hiding rather than deleting, and every decision records who made it on both the report and the post — so a wrong call is recoverable and the evidence survives the decision.

The Veil marketing site

theveilwedding.com — the public site, vendor portal and staff console all ship from one Next.js 15 application.

10. Pricing as an Engineering Problem

Weddings invert subscription economics: a couple does not renew, they have the wedding and leave forever. So the product sells three durations of one identical feature set rather than three feature tiers — monthly, annual, and a one-time "through your wedding day" plan. A couple splitting a feature list across three prices has to work out what they are giving up, and the honest answer is nothing.

Because onboarding captures the wedding date, the paywall reads it and leads with the plan that actually covers the wedding. Eighteen months out, an annual plan would expire weeks before the day — the worst possible moment and a refund conversation waiting to happen — so the one-time plan leads. Under six months, annual is worse value and the couple would feel it, so monthly leads. Every rung stays visible and buyable; the date only decides emphasis.

The one-time plan is implemented as a Stripe payment, not a 24-month subscription, because "one price, through your wedding day" must not quietly renew afterwards. And the checkout refuses a wedding date already in the past rather than charging for time the couple already has.

The Veil store feature graphic

Shipped to the App Store and Google Play from a single Expo codebase, with store-parity pricing reconciled against the edge functions that actually charge.

ENGINEERING DISCIPLINE

Migrations are append-only and numbered

257 of them, applied in order, never edited after the fact. Every one carries a header explaining why — a convention, not decoration. The reasoning has repeatedly mattered more than the SQL, because the next person to touch a policy needs to know what it was protecting against.

RLS is the boundary; a hidden button proves nothing

Permission changes are verified with impersonated, rolled-back transactions and the evidence is recorded in a role test plan. If the client is the only thing enforcing a rule, the rule is not enforced.

Judge writes on row count, never on absence of an error

An UPDATE denied by RLS affects zero rows and throws nothing. That single property produced two false "passes" before it was written down — a test that only checks for an exception will happily certify a policy that is wide open.

Performance lives in the policies

Every RLS policy wraps its auth lookup in a scalar subquery, because a bare call is re-evaluated per row. Every foreign key in the public schema carries a covering index — those columns are exactly what the policy predicates filter on. Redundant policies subsumed by a broader predicate were dropped rather than left to accumulate.

No hotlinked images, ever

Every asset ships bundled. A wedding guest opening an invite on venue wifi is the worst possible place to depend on a third-party CDN, and the day it matters is the one day that cannot be retried.

Claims get reconciled against code

Pricing and tiering documents are periodically re-checked line by line against the gate that enforces them. Two drifts — an "unlimited" cap that was actually five, and a paid feature that had always been free — were caught before they reached a store listing. Where the document and the code disagree, the code is right.

STACK AT A GLANCE

Consumer App

  • React Native 0.81 + Expo 54
  • React 19, React Native Web
  • Reanimated, SVG, Maps, Camera
  • RevenueCat (native IAP)
  • Push notifications + deep links
  • iOS · Android · Web from one source

Web & Portal

  • Next.js 15 (App Router)
  • TypeScript, Tailwind v4
  • Framer Motion
  • Route groups: marketing / portal / admin
  • Invite + join hand-off routes
  • Deployed on Vercel

Data & Backend

  • PostgreSQL 17 with RLS throughout
  • SECURITY DEFINER access functions
  • 550 numbered migrations
  • 75 Deno edge functions
  • Auth, storage, MFA / 2FA recovery
  • Audit log + moderation queue

Integrations

  • Stripe + Stripe Connect
  • OpenAI (music, checklist, moderation)
  • Spotify (taste signal + export)
  • Google Places & Calendar
  • Amazon product search / link parsing
  • Transactional email + web push
  • QuickBooks Online (OAuth, accounting sync)
  • Public REST API, webhooks + a Zapier app
  • MCP server with OAuth 2.1 (Claude, ChatGPT)
  • Gmail API: paced outreach batches

What I actually take from this build

Every hard problem in The Veil turned out to be the same problem in a different costume: where does the truth live? Not in the client, which can be edited. Not in a document that says what the code does, which drifts. It lives in the database that grants the row, the function that charges the card, and the migration that explains why the policy is shaped the way it is. Everything above — the two predicates, the server-resolved price, the definer functions, the append-only migration history — is that one idea applied until it held.