Skip to content
pavel·smolin
Wróć do bloga
Studium przypadku7 min czytania

Jak zbudowałem Council: jeden backend na Next.js + Supabase, dwie aplikacje i trzy modele AI

Studium przypadku Council: backend na Next.js i Supabase dla aplikacji webowej i Expo, pipeline syntezy przez SSE oraz jedna subskrypcja łącząca Stripe i RevenueCat.

Next.jsTypeScriptReact NativeSupabaseRevenueCat

Zadaj trudne pytanie jednemu modelowi AI, a dostaniesz jedną opinię. Zadaj to samo pytanie trzem niezależnym modelom (nie pozwalając im widzieć swoich odpowiedzi) i pogódź wyniki, a dostaniesz coś bliższego konsylium drugiej opinii. Właśnie tym jest Council: wpisujesz decyzję, trzy modele analizują ją niezależnie od siebie, a syntezator łączy ich wnioski w jedną rekomendację, wynik zgodności i wyraźny podział na to, gdzie eksperci się zgadzają, a gdzie się różnią.

Council zbudowałem jako prawdziwy produkt, a nie demo. To aplikacja webowa na Next.js 16 oraz aplikacja mobilna na React Native, które obecnie czekają na przegląd w App Store i Google Play, przy czym każde konto, sesja i subskrypcja są uzgadniane przez jeden backend na Next.js + Supabase. Oto architektura, która za tym stoi: części, które naprawdę ciekawie było budować, oraz te, które okazały się po cichu trudne.

Ekran pytania w Council: wpisanie decyzji i przycisk zwołania council
Ekran pytania: pierwszy council można zwołać bez konta.

Co oznacza „council”

Council jednocześnie sadza przy stole trzy modele, a naturalnym topowym składem są GPT-5.5, Claude Opus 4.8 i Gemini 3.1 Pro. Trójka nie jest jednak sztywnym limitem wbudowanym w produkt: to po prostu rozmiar składu, a lista modeli obejmuje zarówno te darmowe, jak i modele pro, do których dostęp jest weryfikowany po stronie serwera na podstawie subskrypcji. Już teraz jako opcje do wyboru dostępne są Grok, DeepSeek, Llama, Mistral i Qwen:

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" },
];

Council potrzebuje co najmniej dwóch opinii, żeby było co uzgadniać (MIN_SEATED), a interfejs, walidacja i synteza bazują na SEAT_COUNT, a nie na zaszytej gdzieś w kodzie trójce. Dodanie nowego dostawcy sprowadza się do napisania jednej funkcji uruchamiającej i dodania wpisów do listy modeli, bez dotykania rozgałęzienia zapytań, siatki kart czy promptu syntezy. To właśnie sprawia, że „wkrótce więcej modeli” jest małą zmianą, a nie przepisaniem od zera.

Architektura w skrócie

Architektura systemu CouncilAplikacja webowa na Next.js i aplikacja mobilna na Expo/React Native komunikują się z jednym API na Next.js, opartym na jednym projekcie Supabase, które rozdziela zapytania do kilku dostawców LLM i łączy ich odpowiedzi przez syntezator.Aplikacja webowa (Next.js)trycouncil.appExpo / React NativeiOS + AndroidNext.js APISupabase: Auth · Postgres · RLSOpenAIAnthropicGooglexAIDeepSeeki inneSyntezator (Claude)wynik zgodności · zgodności · rozbieżności↓ przesyłane strumieniowo z powrotem do klienta, który zapytał, jako Server-Sent Events
Jedno API oparte na Supabase obsługuje oba klienty i rozsyła zapytania do wszystkich modeli na pokładzie.

Jeden projekt Supabase jest jedynym źródłem prawdy (uwierzytelnianie, tabele Postgres, row-level security) dla obu klientów. Aplikacja na Next.js to nie tylko frontend webowy: to API, które wywołuje każdy klient, w tym aplikacja mobilna. React Native nigdy nie rozmawia bezpośrednio z Supabase ani z dostawcami modeli: wywołuje tę samą trasę /api/council, co przeglądarka.

Jak trzech ekspertów łączy się w strumieniu w jedną odpowiedź

POST /api/council zwraca text/event-stream, a nie pojedynczy blob JSON. Jedno posiedzenie Council może trwać od dziesięciu do dwudziestu sekund (trzy wywołania modeli plus przebieg syntezy), a fałszywy pasek postępu nałożony na blokujące zapytanie to gorsze doświadczenie niż pokazanie tego, co faktycznie się dzieje:

type CouncilEvent =
  | { type: "expert"; index: number; provider: string; name: string; status: "done" | "error"; error?: string }
  | { type: "stage"; index: number }        // 0–4, skumulowana lista kontrolna syntezy
  | { type: "done"; result: CouncilResult } // zdarzenie końcowe
  | { type: "error"; message: string; details?: unknown };

Każde wywołanie dostawcy jest niezależnie opakowane: awaria jednego eksperta nie wywraca całego przebiegu. Każdy runner dostawcy zamiast rzucać wyjątkiem, zwraca ExpertResult z answer: null i tekstem błędu, a syntezator kontynuuje pracę na podstawie tego podzbioru, który faktycznie odpowiedział. Jeśli Gemini przekroczy limit czasu, klient dostaje zdarzenie expert ze status: "error" dla tej karty, a wynik końcowy zawiera wpis warnings zamiast fatalnego błędu.

Trzy karty ekspertów, każda z własnym poziomem pewności i uzasadnieniem
Karta każdego eksperta pojawia się w strumieniu, gdy tylko dany model odpowie.

To także miejsce, w którym TypeScript naprawdę się przydaje. Odpowiedź SDK dostawcy ma typ unknown, dopóki nie przejdzie przez parser, który weryfikuje oczekiwany kształt ({recommendation, confidence, reasoning}) i zwraca wąski, zaufany typ. Nic dalej w łańcuchu (writer SSE, siatka kart, prompt syntezy) nie dotyka surowej odpowiedzi dostawcy: operuje na wartości, która została już raz zweryfikowana, w jedynym miejscu, gdzie zły kształt w ogóle mógłby się przedostać.

Karta z rekomendacją syntezatora
Jedna zsyntetyzowana rekomendacja zamiast trzech odpowiedzi do samodzielnego uzgodnienia.
Wynik zgodności wraz z listą zgodności i rozbieżności
Wynik zgodności plus jawna lista tego, gdzie eksperci się zgadzają, a gdzie nie.

Jedna tożsamość, dwie aplikacje

Oba klienty uwierzytelniają się względem tego samego projektu Supabase (OAuth Google i Apple), ale Council nie wymaga konta, żeby po prostu spróbować: anonimowi odwiedzający dostają ciasteczko UUID, które ogranicza zasięg ich pytań i limitu. Gdy ktoś się zaloguje, jego anonimowa historia zostaje przypisana do nowego konta jednym zapytaniem:

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 };
}

Żadnego interfejsu do scalania, żadnego kroku „zaimportuj swoją historię”: przeglądarka, która zadawała pytania, jest tożsamością, dopóki nie przejmie jej prawdziwe konto.

Udostępnianie bez konta

Każde zakończone posiedzenie Council jest zapisywane w tabeli sessions z user_id lub anon_id i może je odczytać każdy, kto ma link (/s/[id] w wersji webowej, deep link council://s/[id] na urządzeniach mobilnych), bez konieczności logowania się, by je zobaczyć. Granica prywatności to nie kontrola uprawnień rozrzucona po całej trasie, lecz fakt, że publiczna ścieżka odczytu w ogóle nigdy nie wybiera niczego poza kolumnami publicznymi:

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 i anon_id po prostu nigdy nie pojawiają się w zapytaniu, więc nie ma żadnej ścieżki w kodzie, która mogłaby je wyciek na publiczną stronę. Udostępnienie wyniku daje też jednorazowy bonus w postaci kilku dodatkowych darmowych pytań: wystarczająco, by nagrodzić tę akcję, ale nie na tyle, by zmienić ją w pętlę do farmienia. A właściciel sesji może oznaczyć wynik jako discoverable, co uruchamia zadanie w tle dodające go do mapy witryny: prawdziwe rozmowy użytkowników stające się indeksowalnymi stronami to mały element programistycznego SEO, wyrastający z funkcji produktu, a nie osobny wysiłek contentowy.

Dwaj dostawcy płatności, jedna prawda

Council sprzedaje tę samą subskrypcję przez dwa sklepy: Stripe w wersji webowej, RevenueCat w mobilnej (opakowujący zakupy w aplikacji Apple i Google). Niezależnie od tego, przez który sklep ktoś zapłacił, pytanie „czy to konto Pro” musi mieć dokładnie jedną odpowiedź, więc oba webhooki zapisują do tej samej tabeli:

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

Webhook RevenueCat to niewielki automat stanów zbudowany na zdarzeniach cyklu życia zakupów Apple/Google, zmapowanych na to samo wywołanie upsertSubscription, którego używa webhook Stripe:

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,
  });
}

Każda kontrola limitu i decyzja o paywallu w aplikacji odczytuje z tej jednej tabeli: nigdy nie musi wiedzieć ani przejmować się tym, przez który sklep zapłacił dany użytkownik. Jedna pułapka, o której warto wspomnieć każdemu, kto robi to samo: natywny moduł RevenueCat nie jest dostępny w Expo Go, tylko w prawdziwym buildzie deweloperskim, co oznacza, że testowanie zakupów nie mieści się w najszybszej lokalnej pętli, więc trzeba na to zarezerwować czas przy planowaniu wdrożenia subskrypcji mobilnych. Płatności kryptowalutowe są kolejnym punktem na mapie drogowej, jako trzeci dostawca uzgadniany w tej samej tabeli, a nie w równoległym systemie.

Jeden projekt Supabase, dwa niezależnie zakodowane klienty

Łatwo byłoby tu przesadzić, więc dla ścisłości: aplikacja webowa i mobilna nie dzielą wspólnego pakietu kodu. Nie łączy ich żaden workspace monorepo: mobile/ ma własny analytics.ts, własne typy API, napisane niezależnie, a nie importowane z głównego lib/. Tym, co faktycznie jest wspólne, jest sam projekt Supabase (schemat, polityki RLS i jedno źródło prawdy w Postgresie) oraz kontrakt API: aplikacja mobilna to po prostu kolejny klient uderzający w to samo /api/council, ten sam endpoint przejmowania sesji, ten sam stan subskrypcji. Obie bazy kodu odzwierciedlają swoje konwencje (nazwy zdarzeń, zasady limitów, przepływ uwierzytelniania) dzięki dyscyplinie, udokumentowanej obok siebie we własnym pliku wytycznych każdej aplikacji, a nie przez wspólny import. Dla produktu złożonego z dwóch aplikacji, budowanego solo, okazało się to właściwym poziomem powiązania: jeden backend, który nie może się rozjechać, i dwa klienty, które mogą rozwijać się we własnym tempie.

Wydawanie na wielu platformach, solo

Aplikacja webowa jest wdrażana na Vercel. Aplikacja mobilna jest budowana przez pipeline EAS Build od Expo, zarówno dla binarek iOS, jak i Android; w chwili publikacji tego wpisu obie czekają na przegląd w sklepach. CI przy każdym pushu uruchamia lint, typecheck i zestaw testów dla aplikacji webowej, a także przebieg typecheck dla aplikacji mobilnej: dzięki temu zmiana we wspólnym kontrakcie API nie może po cichu zepsuć klienta, którego akurat nie masz przed oczami.

Wypróbuj

Council działa już pod adresem trycouncil.app; aplikacje na iOS i Android czekają na przegląd w sklepach i powinny pojawić się wkrótce. Jeśli budujesz coś o podobnym kształcie (aplikacja webowa, aplikacja mobilna, jeden backend, prawdziwe subskrypcje, wdrożone od początku do końca), to właśnie taki typ projektów podejmuję. Skontaktuj się ze mną, jeśli potrzebujesz pomocy w doprowadzeniu produktu od zera do produkcji, pełnym cyklem, na webie i mobile.