Skip to content
pavel·smolin
Назад в блог
Кейс-стади7 мин чтения

Как я построил Council: единый бэкенд на Next.js + Supabase, два приложения и три ИИ-модели

Кейс о Council: бэкенд на Next.js и Supabase для веб- и Expo-приложений, пайплайн синтеза через SSE и единая подписка, объединяющая Stripe и RevenueCat.

Next.jsTypeScriptReact NativeSupabaseRevenueCat

Задайте сложный вопрос одной ИИ-модели, и получите одно мнение. Задайте тот же вопрос трём независимым моделям (не давая им видеть ответы друг друга), сведите результаты воедино, и это будет уже похоже на консилиум для второго мнения. Это и есть Council: вы вводите решение, которое нужно обдумать, три модели анализируют его независимо друг от друга, а синтезатор сводит их выводы в одну рекомендацию, оценку консенсуса и разбивку по пунктам: в чём эксперты сошлись, а в чём разошлись.

Council — реальный продукт, а не демо. Это веб-приложение на Next.js 16 и мобильное приложение на React Native, которые сейчас проходят проверку в App Store и Google Play, причём все аккаунты, сессии и подписки сведены через один бэкенд на Next.js + Supabase. Дальше расскажу про архитектуру, которая за этим стоит: части, которые было по-настоящему интересно строить, и те, что оказались незаметно, но ощутимо сложными.

Экран запроса Council: ввод вопроса и кнопка «Собрать совет»
Экран запроса: совет можно созвать без аккаунта.

Что значит «совет»

Совет одновременно рассаживает три модели за стол, и естественный топ-состав такой: GPT-5.5, Claude Opus 4.8 и Gemini 3.1 Pro. При этом тройка не жёсткий потолок, зашитый в продукт, а просто размер скамейки запасных, и список моделей включает как бесплатные, так и pro-модели, доступ к которым проверяется на сервере по статусу подписки. Уже сейчас в качестве выбираемых опций доступны Grok, DeepSeek, Llama, Mistral и 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" },
];

Совету нужно минимум два мнения, чтобы было что сводить воедино (MIN_SEATED), а интерфейс, валидация и синтез ориентируются на SEAT_COUNT, а не на зашитую где-то в коде тройку. Добавление нового провайдера сводится к написанию одной функции-раннера и добавлению записей в список моделей, без изменений в разветвлении запросов, сетке карточек или промпте синтеза. Именно это делает «скоро добавим ещё моделей» небольшим изменением, а не переписыванием с нуля.

Архитектура целиком

Архитектура системы CouncilВеб-приложение на Next.js и мобильное приложение на Expo/React Native обращаются к одному Next.js API поверх единого проекта Supabase, который рассылает запросы нескольким провайдерам LLM и сводит их ответы через синтезатор.Веб-приложение (Next.js)trycouncil.appExpo / React NativeiOS + AndroidNext.js APISupabase: Auth · Postgres · RLSOpenAIAnthropicGooglexAIDeepSeekи другиеСинтезатор (Claude)оценка консенсуса · согласия · расхождения↓ стримится обратно тому клиенту, который спросил, через Server-Sent Events
Один API на Supabase обслуживает оба клиента и рассылает запросы всем моделям на борту.

Один проект Supabase — единый источник истины (аутентификация, таблицы Postgres, row-level security) для обоих клиентов. Приложение на Next.js — это не просто веб-фронтенд, а API, к которому обращается любой клиент, включая мобильное приложение. React Native никогда не общается с Supabase или провайдерами моделей напрямую: он вызывает тот же маршрут /api/council, что и браузер.

Как три эксперта стримятся в один ответ

POST /api/council возвращает text/event-stream, а не единый JSON. Один совет может занимать от десяти до двадцати секунд (три вызова моделей плюс проход синтеза), и фейковый прогресс-бар поверх блокирующего запроса даёт куда худший опыт, чем честный показ того, что происходит на самом деле:

type CouncilEvent =
  | { type: "expert"; index: number; provider: string; name: string; status: "done" | "error"; error?: string }
  | { type: "stage"; index: number }        // 0–4, накопительный чек-лист синтеза
  | { type: "done"; result: CouncilResult } // финальное событие
  | { type: "error"; message: string; details?: unknown };

Каждый вызов провайдера независимо обёрнут: падение одного эксперта не валит весь прогон. Любой раннер провайдера вместо выброса исключения возвращает ExpertResult с answer: null и строкой error, а синтезатор работает с тем подмножеством, которое реально ответило. Если у Gemini истекает тайм-аут, клиент получает событие expert со status: "error" для этой карточки, а в финальном результате появляется запись warnings вместо фатального сбоя.

Три карточки экспертов, у каждой своя оценка уверенности и рассуждение
Карточка каждого эксперта появляется в стриме сразу, как только эта модель отвечает.

Именно здесь TypeScript окупает себя. Ответ SDK провайдера имеет тип unknown, пока не пройдёт через парсер, который проверяет ожидаемую форму ({recommendation, confidence, reasoning}) и возвращает узкий, проверенный тип. Ничто ниже по цепочке (запись SSE, сетка карточек, промпт синтеза) не трогает сырой ответ провайдера: оно работает со значением, которое уже один раз проверено, в единственном месте, где неверная форма вообще могла бы проникнуть.

Карточка с итоговой рекомендацией синтезатора
Один синтезированный ответ вместо трёх, которые пришлось бы сверять самому.
Оценка совпадения мнений и списки согласий и расхождений
Оценка совпадения плюс явный список того, в чём согласия, а в чём расхождения.

Один аккаунт, два приложения

Оба клиента аутентифицируются через один и тот же проект Supabase (OAuth Google и Apple), но Council не требует аккаунта, чтобы просто попробовать сервис: анонимные посетители получают UUID-куки, которая привязывает их вопросы и квоту. Когда пользователь входит в аккаунт, вся его анонимная история переносится на новый аккаунт одним запросом:

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

Никакого UI для слияния, никакого шага «импортировать историю»: браузер, который задавал вопросы, и есть идентичность, пока её не заберёт себе настоящий аккаунт.

Делиться без аккаунта

Каждый завершённый совет сохраняется в таблицу sessions с user_id или anon_id и доступен для чтения любому, у кого есть ссылка (/s/[id] в вебе, диплинк council://s/[id] в мобильном приложении), без необходимости входить в аккаунт. Граница приватности здесь не проверка прав, разбросанная по маршруту, а то, что публичный путь чтения вообще никогда не выбирает ничего, кроме публичных колонок:

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 и anon_id попросту никогда не попадают в запрос, поэтому нет ни одного пути в коде, который мог бы утечь их на публичную страницу. Публикация результата также даёт одноразовый бонус в виде нескольких дополнительных бесплатных вопросов: этого достаточно, чтобы поощрить действие, но не превратить его в фармящийся цикл. А владелец сессии может пометить результат как discoverable, что запускает фоновую задачу, добавляющую его в sitemap: реальные диалоги пользователей, которые становятся индексируемыми страницами, это небольшой кусок программного SEO, выросший из фичи продукта, а не отдельная контентная работа.

Два платёжных провайдера, одна истина

Council продаёт одну и ту же подписку через два магазина: Stripe в вебе, RevenueCat в мобильном приложении (он оборачивает встроенные покупки Apple и Google). Через какой бы магазин человек ни заплатил, на вопрос «это Pro-аккаунт?» должен быть ровно один ответ, поэтому оба вебхука пишут в одну и ту же таблицу:

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

Вебхук RevenueCat — это небольшой конечный автомат поверх событий жизненного цикла покупок Apple/Google, отображённых на тот же вызов upsertSubscription, что использует и вебхук 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,
  });
}

Любая проверка квоты и решение о пейволле в приложении читают из этой единственной таблицы: ей никогда не нужно знать или заботиться о том, через какой магазин заплатил конкретный пользователь. Единственная загвоздка, которую стоит отметить всем, кто делает то же самое: нативный модуль RevenueCat недоступен в Expo Go, только в настоящей dev-сборке, а значит тестирование покупок не помещается в самый быстрый локальный цикл разработки: закладывайте на это время при планировании мобильного запуска подписок. Криптоплатежи — следующий пункт в дорожной карте, третий провайдер, который сведётся в ту же таблицу, а не в параллельную систему.

Один проект Supabase, два независимо написанных клиента

Здесь легко приукрасить, но если быть точным: веб- и мобильное приложения не делят общий пакет кода. Их не связывает никакой monorepo-workspace: у mobile/ свой analytics.ts, свои типы API, написанные независимо, а не импортированные из корневого lib/. По-настоящему общими являются сам проект Supabase (схема, политики RLS и единый источник истины в Postgres) и контракт API: мобильное приложение — это просто ещё один клиент, который обращается к тому же /api/council, той же ручке для привязки сессий, тому же состоянию подписки. Обе кодовые базы отражают конвенции друг друга (имена событий, правила квот, флоу аутентификации) за счёт дисциплины, задокументированной бок о бок в собственном гайд-файле каждого приложения, а не за счёт общего импорта. Для соло-продукта из двух приложений это оказалась правильная степень связанности: один бэкенд, который не может разъехаться, и два клиента, которые могут развиваться в своём темпе.

Кросс-платформенный релиз в одиночку

Веб-приложение деплоится на Vercel. Мобильное приложение собирается через пайплайн EAS Build от Expo для сборок под iOS и Android; на момент публикации этого поста оба находятся на проверке в сторах. CI на каждый пуш прогоняет линт, тайпчек и тестовый набор для веб-приложения, плюс проход тайпчека для мобильного: так изменение общего контракта API не может незаметно сломать тот клиент, который в данный момент не перед глазами.

Попробовать

Council уже работает на trycouncil.app; приложения для iOS и Android проходят проверку в сторах и скоро должны выйти. Если вы строите что-то похожей формы (веб-приложение, мобильное приложение, один бэкенд, настоящие подписки, доведённые до релиза от и до), это как раз тот тип проектов, за которые я берусь. Свяжитесь со мной, если нужна помощь довести продукт с нуля до продакшена, полным циклом, на вебе и мобильном.