Skip to content
pavel·smolin
Back to blog
Case Study7 min read

How I Built Council: A Next.js + Supabase Backend, Two Apps, Three AI Models

A case study of Council: a Next.js and Supabase backend behind a web app and an Expo app, an SSE synthesis pipeline, and Stripe + RevenueCat reconciled into one subscription.

Next.jsTypeScriptReact NativeSupabaseRevenueCat

Ask one AI model a hard question and you get an opinion. Ask three independent models the same question (without letting them see each other’s answers) and reconcile the results, and you get something closer to a second-opinion panel. That’s Council: you type in a decision, three models each analyze it in isolation, and a synthesizer reconciles their takes into one recommendation, a consensus score, and an explicit breakdown of where the experts agree and where they split.

I built Council as a real product, not a demo. It’s a Next.js 16 web app and a React Native mobile app, currently sitting in iOS and Android store review, with every account, session, and subscription reconciled through one Next.js + Supabase backend. This is the architecture behind it: the parts that were genuinely interesting to build, and the ones that turned out to be quietly hard.

Council's ask screen: type in a decision and tap Convene the council
The ask screen — a council can be convened without an account.

What “council” means

A council seats three models at a time, and a natural flagship lineup is GPT-5.5, Claude Opus 4.8, and Gemini 3.1 Pro. Three isn’t a ceiling baked into the product though; it’s just the bench size, and the roster spans both free and pro-tier models, gated server-side by subscription. It already includes Grok, DeepSeek, Llama, Mistral, and Qwen as selectable options:

export const SEAT_COUNT = 3;
export const MIN_SEATED = 2;

export const ROSTER: RosterModel[] = [
  { provider: "openai", model: "gpt-5.5", name: "GPT-5.5", tier: "free" },
  { provider: "anthropic", model: "claude-opus-4-8", name: "Claude Opus 4.8", tier: "free" },
  // ...
  { provider: "xai", model: "grok-4.3", name: "Grok 4.3", tier: "pro" },
  { provider: "deepseek", model: "deepseek-v4-pro", name: "DeepSeek V4 Pro", tier: "pro" },
];

A council needs at least two takes to have anything to reconcile (MIN_SEATED), and the UI, validation, and synthesis all size themselves off SEAT_COUNT rather than a hardcoded “3” anywhere downstream. Adding a new provider is a matter of writing one runner function and adding roster entries, not touching the fan-out, the card grid, or the synthesis prompt. That’s the groundwork for “more models soon” being a small change, not a rewrite.

Architecture at a glance

Council system architectureA Next.js web app and an Expo React Native mobile app both call one Next.js API backed by a single Supabase project, which fans out to several LLM providers and reconciles their answers through a synthesizer.Next.js Web Apptrycouncil.appExpo / React NativeiOS + AndroidNext.js APISupabase: Auth · Postgres · RLSOpenAIAnthropicGooglexAIDeepSeek+ moreSynthesizer (Claude)consensus score · agreements · divergences↓ streamed back to whichever client asked, as Server-Sent Events
One Supabase-backed API serves both clients and fans out to every model on the board.

One Supabase project is the single source of truth (auth, the Postgres tables, row-level security) for both clients. The Next.js app isn’t just the web frontend; it’s the API every client calls, including the mobile app. React Native never talks to Supabase or the model providers directly; it calls the same /api/council route the browser does.

Streaming three experts into one answer

POST /api/council returns text/event-stream, not a single JSON blob. A council can take ten to twenty seconds (three model calls plus a synthesis pass), and a fake progress bar over a blocking request is a worse experience than showing what’s actually happening:

type CouncilEvent =
  | { type: "expert"; index: number; provider: string; name: string; status: "done" | "error"; error?: string }
  | { type: "stage"; index: number }        // 0-4, cumulative synthesis checklist
  | { type: "done"; result: CouncilResult } // final event
  | { type: "error"; message: string; details?: unknown };

Each provider call is independently wrapped: one failing expert doesn’t take down the run. Every provider runner returns an ExpertResult with answer: null and an error string instead of throwing, and the synthesizer proceeds from whichever subset actually responded. If Gemini times out, the client gets an expert event with status: "error" for that card, and the final result carries a warnings entry instead of a fatal failure.

Three expert cards, each with its own confidence score and reasoning
Each expert’s card streams in as soon as that model responds.

This is also where TypeScript earns its keep. A provider SDK’s response is unknown until it’s been through a parser that validates the expected shape ({recommendation, confidence, reasoning}) and returns a narrow, trusted type. Nothing downstream (the SSE writer, the card grid, the synthesis prompt) touches a raw provider response. It touches a value that’s already been checked once, at the one place a bad shape could actually get in.

The synthesizer's top-pick recommendation card
One synthesized recommendation, not three answers to reconcile yourself.
An alignment score with lists of where the experts agree and diverge
The alignment score, plus the explicit agreements and divergences behind it.

One identity, two apps

Both clients authenticate against the same Supabase project (Google and Apple OAuth), but Council doesn’t require an account to try it: anonymous visitors get a UUID cookie that scopes their questions and quota. When someone signs in, their anonymous history gets reassigned to the new account in one query:

export async function claimSessions(
  userId: string,
  anonId: string
): Promise<{ claimed: number }> {
  const supabase = createServiceRoleClient();
  const { data, error } = await supabase
    .from("sessions")
    .update({ user_id: userId })
    .eq("anon_id", anonId)
    .is("user_id", null)
    .select("id");

  if (error) throw error;
  return { claimed: data?.length ?? 0 };
}

No merge UI, no “import your history” step: the browser that asked the questions is the identity, until a real account claims it.

Share without an account

Every completed council is persisted to a sessions table with either a user_id or an anon_id, and is readable by anyone with the link (/s/[id] on web, a council://s/[id] deep link on mobile) with no sign-in required to view it. The privacy boundary isn’t a permissions check scattered through the route; it’s that the public read path only ever selects public columns:

export async function getPublicSession(id: string): Promise<{
  question: string;
  result: CouncilResult;
  createdAt: string;
  discoverable: boolean;
} | null> {
  const supabase = createServiceRoleClient();
  const { data, error } = await supabase
    .from("sessions")
    .select("question, result, created_at, discoverable")
    .eq("id", id)
    .maybeSingle();

  if (error) throw error;
  return data
    ? { question: data.question, result: data.result as CouncilResult, createdAt: data.created_at, discoverable: data.discoverable }
    : null;
}

user_id and anon_id simply never appear in the query, so there’s no code path that can leak them to a public page. Sharing a result also grants a one-time bonus of a few extra free questions, enough to reward the action without turning it into a farmable loop. And a session owner can opt a result into discoverable, which feeds a background job that lists it in the sitemap: real user-generated conversations becoming indexable pages is a small piece of programmatic SEO growing out of a product feature rather than a separate content effort.

Two payment providers, one truth

Council sells the same subscription through two stores: Stripe on web, RevenueCat on mobile (wrapping Apple’s and Google’s in-app purchases). Whichever store a person paid through, “is this account Pro” needs exactly one answer, so both webhooks write into the same table:

export interface SubscriptionRow {
  userId: string;
  status: "active" | "expired";
  provider: "stripe" | "revenuecat";
  providerSubId: string | null;
  stripeCustomerId: string | null;
  currentPeriodEnd: string | null;
}

The RevenueCat webhook is a small state machine over Apple/Google’s purchase lifecycle events, mapped onto the same upsertSubscription call the Stripe webhook uses:

const GRANT_ACCESS = new Set(["INITIAL_PURCHASE", "RENEWAL", "UNCANCELLATION"]);
const REVOKE_ACCESS = new Set(["EXPIRATION"]);

if (GRANT_ACCESS.has(type)) {
  await upsertSubscription({
    userId: app_user_id,
    status: "active",
    provider: "revenuecat",
    providerSubId: original_purchase_id ?? null,
    currentPeriodEnd: expiration_at_ms ? new Date(expiration_at_ms).toISOString() : null,
  });
}

Every quota check and paywall decision in the app reads this one table: it never has to know or care which store a given user paid through. The one gotcha worth flagging for anyone doing the same: RevenueCat’s native module isn’t available in Expo Go, only in a real dev build, which means purchase testing can’t happen in the fastest local loop; budget for that when planning a mobile subscription rollout. Crypto payments are next on the roadmap, as a third provider reconciled into this same table rather than a parallel system.

One Supabase project, two independently-coded clients

It would be easy to overclaim here, so to be precise: the web app and the mobile app don’t share a code package. There’s no monorepo workspace linking them: mobile/ has its own analytics.ts, its own API types, written independently rather than imported from the root lib/. What’s actually shared is the Supabase project itself (schema, RLS policies, and the single Postgres source of truth) and the API contract: the mobile app is just another client hitting the same /api/council, the same session-claim endpoint, the same subscription state. The two codebases mirror each other’s conventions (event names, quota rules, auth flow) by discipline, documented side by side in each app’s own guidance file, not by a shared import. For a two-app product built solo, that turned out to be the right amount of coupling: one backend that can’t drift, two clients that can evolve at their own pace.

Shipping cross-platform, solo

The web app deploys to Vercel. The mobile app builds through Expo’s EAS Build pipeline for both the iOS and Android binaries; as of this post, both are sitting in store review. CI runs lint, typecheck, and the test suite for the web app, plus a typecheck pass for the mobile app, on every push, so a change to the shared API contract can’t silently break the client that isn’t in front of you while you’re working.

Try it

Council is live at trycouncil.app; the iOS and Android apps are pending store review and should follow shortly. If you’re building something that needs this same shape (a web app, a mobile app, one backend, real subscriptions, shipped end to end), that’s the kind of project I take on. Get in touch if you want help taking a product from zero to production, full-cycle, across web and mobile.