Home

With Thanks

Effortlessly collect supplier details from wedding couples.

Problem

Wedding photographers want to tag every supplier accurately, but collecting handles/emails after the event is tedious and error-prone — so credits get missed or tagged inconsistently.

Solution

With Thanks turns credit collection into a low-friction collaboration: the photographer creates an event and shares one private link; the couple adds suppliers; the photographer gets copy-ready Instagram tags and email lists back.

My Role

Founder, Full-Stack Dev, Design


Tech Stack

  • TanStack Start (Vite) - full-stack React via server functions

  • TanStack Query

  • TanStack Form

  • TanStack Pacer - debouncing, throttling, batching (search + UI state)

  • Zod - runtime validation at the request boundary

  • Drizzle ORM - typed data layer, no client-side queries

  • Neon Postgres - DB branching for preview deployments

  • Neon Auth (Better Auth) - photographer sign-in

  • Tailwind CSS + shadcn/ui

  • Vercel - deployment branching (preview deployments)


Engineering

Key constraints and decisions that shaped the product’s access model, UX, and data integrity.

Constraint

Decision

Trade-off

Search is a core workflow, and typing-driven UX can easily create noisy requests and janky UI (especially with dedupe checks and “typeahead” search).

Adopted TanStack Pacer for debounced/throttled state so high-frequency interactions (supplier search inputs, dedupe checks, and copy-to-clipboard feedback) stay responsive and “batch” into fewer requests.

Requires careful tuning of debounce/throttle timing and doesn’t replace server-side protections. Planned: expand into explicit rate-limiting patterns as usage grows.

I wanted the UI to feel lightweight — actions like creating an event or tagging a supplier should feel quick, not like a “workflow” that pulls you away from what you were doing.

Used a drawer-first interaction pattern for common actions (e.g. create event, add/tag supplier) so forms appear in-context instead of full page navigations.

Drawers are effectively modal dialogs, so stacking portal-based UI primitives (popovers/selects/date pickers) can create “popup-on-popup” and focus/portaling issues. In practice this constrained some input choices in favor of simpler components.

I envisioned the app being used primarily on a phone, and didn’t want to maintain separate “desktop vs mobile” layout complexity.

Constrained the main app layout to a phone-like max width so the UI stays consistent and easy to design for, even when opened on desktop.

The desktop experience intentionally doesn’t use the full screen. Some UI patterns need extra care at larger breakpoints to avoid looking sparse while keeping the phone-first feel.

Encourage crowdsourcing of supplier information while minimizing duplicate suppliers as the database grows.

Allow couples to contribute supplier details, but with dedupe guardrails designed to keep the shared supplier database usable over time.

Canonical identifier: supplier email (supports future supplier profile claiming), enforced as a case-insensitive unique constraint on normalized email.

UI guardrail: a “did you mean…?” step that ranks potential duplicates using fuzzy matching on name/email plus email-domain signals (e.g. hello@domain vs info@domain).

Some duplicates will still slip through (aliases, generic inboxes, similar names). Over time, keeping the supplier database clean likely requires manual merge tools or admin workflows.


Architecture

Request flow architecture. Route renders, a hook calls a TanStack Start server function, the server function validates/authorizes, then Drizzle runs against Neon Postgres and returns a DTO back to the UI.
With Thanks request flow: routes and hooks call TanStack Start server functions, which validate/authorize before Drizzle queries run against Neon Postgres.

With Thanks is a small full-stack app built on TanStack Start, where backend capabilities are expressed as type-safe server functions instead of a separate API service.

I chose TanStack (Start + Router) for strong end-to-end type-safety at the routing boundary and its ergonomics for layering in middleware-style enforcement over time (planned). Zod fits cleanly into this: routes can validate and type search params, and server functions validate incoming payloads at the request boundary before any business logic runs.

At a high level, routes render UI and delegate data loading and mutations to hooks (TanStack Query + useServerFn). Those hooks call server functions (createServerFn), which validate inputs and enforce authorisation before executing database reads/writes.

The client never talks to the database directly. All database reads and writes run server-side through Drizzle against Neon Postgres using server environment credentials. Client tokens are used strictly for request authorisation, not for database access.

A key product-specific detail is that the app supports two access modes (session dashboard vs share-link collaboration), but the architecture remains consistent: both modes still flow through the same server-function boundary.

  • Route layer: file-based routes compose UI and local state.

  • Hook layer: TanStack Query caching/invalidation + useServerFn calls.

  • Server boundary: server functions validate inputs and authorize capabilities.

  • Data layer: Drizzle queries against Neon Postgres (server-side env creds).


Deep Dive: Dual access modes (session vs share link)

The Problem

With Thanks has two very different user experiences:

The architecture challenge is to make the couple experience extremely low-friction while keeping the higher-risk actions protected and permissions easy to reason about.

What I Built

Access-mode architecture. Photographer uses a session-authenticated dashboard; couple uses a share-link token. Authorisation happens in server functions before Drizzle queries run against Neon Postgres.
With Thanks access-mode architecture: session-authenticated event admin plus a share-link authenticated collaboration surface.
  • Session-authenticated dashboard (photographer): create/manage events and copy outputs.

  • Share-link authenticated collaboration (couple): add suppliers and update credits via a private event link with a token in the URL.

  • Single backend surface: both modes call the same TanStack Start server functions; each function enforces its own required authorisation

  • Server-side persistence: after authorisation, all reads/writes happen server-side via Drizzle + Neon Postgres.

The Security Trade-off (and why it’s worth it)

A share link is inherently less secure than a signed-in account: it can be forwarded, screenshotted, or guessed if implemented poorly.

I accepted that additional risk because the product’s core value depends on couples actually completing the data entry — and account creation would destroy the conversion rate for a one-time guest action.

The key is scoping: the share-link mode is intentionally limited to collaboration actions, while event administration stays session-only.

Capability scoping

  • Session only (photographer): manage events (create/list/delete) and access the full dashboard.

  • Session or share link: collaboration on an event (view the event as a couple, add suppliers, add/remove credits).

Mitigations (current + planned hardening)

  • Server-side authorisation per function: every write goes through a server function that checks required auth before hitting the database.

  • Planned: rotatable share links: rotate the event’s share token to invalidate previously shared links without introducing permission levels or couple accounts.

  • Planned: additional abuse controls: rate limiting and tighter scoping/validation as usage grows.

Outcome

  • Photographers get a protected admin surface without adding friction for couples.

  • Couples can contribute quickly from a phone without onboarding.

  • Authorisation stays explicit and capability-based at the server-function boundary, so the product can evolve without tangled permissions.


More Projects