Как я построил Council: единый бэкенд на Next.js + Supabase, два приложения и три ИИ-модели
Кейс о Council: бэкенд на Next.js и Supabase для веб- и Expo-приложений, пайплайн синтеза через SSE и единая подписка, объединяющая Stripe и RevenueCat.
Задайте сложный вопрос одной ИИ-модели, и получите одно мнение. Задайте тот же вопрос трём независимым моделям (не давая им видеть ответы друг друга), сведите результаты воедино, и это будет уже похоже на консилиум для второго мнения. Это и есть Council: вы вводите решение, которое нужно обдумать, три модели анализируют его независимо друг от друга, а синтезатор сводит их выводы в одну рекомендацию, оценку консенсуса и разбивку по пунктам: в чём эксперты сошлись, а в чём разошлись.
Council — реальный продукт, а не демо. Это веб-приложение на Next.js 16 и мобильное приложение на React Native, которые сейчас проходят проверку в App Store и Google Play, причём все аккаунты, сессии и подписки сведены через один бэкенд на Next.js + Supabase. Дальше расскажу про архитектуру, которая за этим стоит: части, которые было по-настоящему интересно строить, и те, что оказались незаметно, но ощутимо сложными.

Что значит «совет»
Совет одновременно рассаживает три модели за стол, и естественный топ-состав такой: 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, а не на зашитую где-то в коде тройку. Добавление нового провайдера сводится к написанию одной функции-раннера и добавлению записей в список моделей, без изменений в разветвлении запросов, сетке карточек или промпте синтеза. Именно это делает «скоро добавим ещё моделей» небольшим изменением, а не переписыванием с нуля.
Архитектура целиком
Один проект 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 проходят проверку в сторах и скоро должны выйти. Если вы строите что-то похожей формы (веб-приложение, мобильное приложение, один бэкенд, настоящие подписки, доведённые до релиза от и до), это как раз тот тип проектов, за которые я берусь. Свяжитесь со мной, если нужна помощь довести продукт с нуля до продакшена, полным циклом, на вебе и мобильном.