Point0 — простой, как tRPC, полноценный фулстек-фреймворк
- четверг, 16 июля 2026 г. в 00:00:07
Хочу сравнить фреймворк Point0 и библиотеку tRPC. Делаю это, потому что квери и мутации в Point0 очень похожи по DX на tRPC, но более удобны ввиду того, что всё же Point0 это фреймворк и он может обладать возможностями, которыми библиотека не может:
Объявлять квери вместе с серверным кодом прямо в клиентских файлах, или наоборот
Автоматически гидрировать и дегидрировать квери клиент при включении SSR
В мутациях отправлять файлы автоматически превращая данные в FormData и обратно
Иметь стабильные индивидуальные урлы для квери и мутаций
Не заставлять зависать редактор кода при увеличении количества эндпоинтов
Возвращать в ответах с сервера на клиент целые серверные компоненты или интерактивные острова
Управлять внешним видом состояний загрузки на страницах и в компонентах
Кроме того, что описано в этой статье, Point0 может много чего ещё, но об этом в других статьях. Сейчас фокусируемся на сравнении с tRPC. Предполагается, что вы с tRPC более менее знакомы, но ниже я всё равно буду показывать примеры реализации на tRPC, чтобы сравнения были видимыми.
Видео по этой статье на Youtube: https://www.youtube.com/watch?v=0CT9vBzywyg
Видео по этой статье на ВК-видео: https://vkvideo.ru/video-227165132_456239212?pl=-227165132_5
Гитхаб фреймворка: https://github.com/1gr14/point0
Документация: https://1gr14.dev/point0
Большая статья с разбором всего фреймворка на хабре: Point0 — фулстек TypeScript-фреймворк на Bun и React, о котором я мечтал
Бенчмарки против Next.js и TanStack Start (чуть устарели, но правдивые): https://github.com/1gr14/point0-benchmarks
Документация по фреймворку одним файлом для ИИ-агента: https://1gr14.dev/llms.txt — скормите агенту, и он ответит на любой вопрос по фреймворку
Я — автор Point0, статья оригинальная, написана специально для хабра
Начнём с классики: получить запись по id. Вот как это выглядит в tRPC. Сначала процедура:
// server/routers/idea.ts import { z } from 'zod' import { publicProcedure, router } from '../trpc' import { prisma } from '../prisma' export const ideaRouter = router({ view: publicProcedure .input(z.object({ id: z.string() })) .query(async ({ input }) => { const idea = await prisma.idea.findUniqueOrThrow({ where: { id: input.id }, }) return { idea } }), // Каждый новый эндпоинт добавляем сюда })
Потом её надо зарегистрировать в общем роутере — руками:
// server/routers/_app.ts import { router } from '../trpc' import { ideaRouter } from './idea' export const appRouter = router({ idea: ideaRouter, // каждый новый роутер дописываем сюда }) export type AppRouter = typeof appRouter
Потом создать клиентские хуки, типизированные типом всего роутера:
// utils/trpc.ts import { createTRPCReact } from '@trpc/react-query' import type { AppRouter } from '../server/routers/_app' export const trpc = createTRPCReact<AppRouter>()
И только теперь можно использовать:
// client/components/idea.tsx import { trpc } from '@/utils/trpc' export const IdeaView = ({ id }: { id: string }) => { const result = trpc.idea.view.useQuery({ id }) if (result.isLoading) return <div>Загрузка...</div> if (result.error) return <div>{result.error.message}</div> return <h1>{result.data.idea.title}</h1> }
Теперь Point0. Квери объявляется в любом файле проекта — целиком, вместе с серверным кодом:
// modules/idea.tsx import { root } from '@/lib/root' import { prisma } from '@/lib/prisma' import * as z from 'zod' export const ideaViewQuery = root.lets .query() // или альтернативный синтаксис: root.lets('query', 'ideaView') // в Point0 все поинты могут объявляться либо короткой, либо длинной нотацией .input(z.object({ id: z.string() })) // схема любой либой: zod, valibot, typebox, ... .loader(async ({ input }) => { const idea = await prisma.idea.findUniqueOrThrow({ where: { id: input.id }, }) return { idea } }) .query() // сюда можно передать опции обычного useQuery/fetchQuery
И используется напрямую — импортируется сама квери, а не хук-прокси. Причём можно даже не импортировать, а прямо использовать там же, где и объявили, рядом со страницей/компонентом.
// modules/idea.tsx export const IdeaView = ({ id }: { id: string }) => { const result = ideaViewQuery.useQuery({ id }) if (result.isLoading) return <div>Загрузка...</div> if (result.error) return <div>{result.error.message}</div> return <h1>{result.data.idea.title}</h1> }
result — оригинальный объект useQuery из react-query, ничего обёрнутого. Все привычные методы react-query живут прямо на квери:
ideaViewQuery.useQuery({ id }) ideaViewQuery.fetchQuery({ id }) ideaViewQuery.prefetchQuery({ id }) ideaViewQuery.invalidateQuery({ id }) ideaViewQuery.setQueryData({ id }, ...) ideaViewQuery.getQueryKey({ id }) // и так далее — весь стандартный набор
Первым аргументом везде идёт инпут — из него собирается уникальный queryKey. Если инпут опциональный или отсутствует, его можно не передавать вовсе.
Разница уже видна: нет файла с роутером, нет AppRouter, нет utils/trpc.ts. Объявил, импортировал, вызвал. Куда при этом делся индекс и почему серверный код не утёк на клиент, будет в двух отдельных разделах ниже.
Процедура в роутере, на клиенте useMutation:
// server/routers/idea.ts export const ideaRouter = router({ // ... update: publicProcedure .input( z.object({ id: z.string(), title: z.string().min(1), content: z.string().min(1), }), ) .mutation(async ({ input }) => { const idea = await prisma.idea.update({ where: { id: input.id }, data: { title: input.title, content: input.content }, }) return { idea } }), // ... })
// client/components/idea.tsx import { trpc } from '@/utils/trpc' export const IdeaEditForm = ({ idea }: { idea: Idea }) => { const utils = trpc.useUtils() const mutation = trpc.idea.update.useMutation({ onSuccess: ({ idea }) => { utils.idea.view.setData({ id: idea.id }, { idea }) }, }) // ... }
Point0 — та же схема, тот же лоадер, только это самостоятельный поинт, самодостаточный и для клиента, и для сервера.
// modules/idea.tsx import { root } from '@/lib/root' import { prisma } from '@/lib/prisma' import { ideaViewQuery } from '@/modules/idea' import { navigate } from '@/lib/navigation' import * as z from 'zod' export const ideaUpdateMutation = root.lets .mutation() .input( z.object({ id: z.string(), title: z.string().min(1), content: z.string().min(1), }), ) .loader(async ({ input }) => { const idea = await prisma.idea.update({ where: { id: input.id }, data: { title: input.title, content: input.content }, }) return { idea } }) .mutation({ onSuccess: ({ idea }) => { ideaViewQuery.setQueryData({ id: idea.id }, { idea }) }, }) export const IdeaEditForm = ({ idea }: { idea: Idea }) => { const mutation = ideaUpdateMutation.useMutation() return ( <form onSubmit={async (e) => { e.preventDefault() const form = new FormData(e.currentTarget) await mutation.mutateAsync({ id: idea.id, title: String(form.get('title')), content: String(form.get('content')), }) await navigate('ideaView', { id: idea.id }) }} > <input name="title" defaultValue={idea.title} /> <textarea name="content" defaultValue={idea.content} /> <button disabled={mutation.isPending}>Сохранить</button> </form> ) }
Методы вроде ideaViewQuery.setQueryData() и invalidateQuery() работают где угодно — в мутации, в обработчике, вне компонента. Квери клиент в Point0 общий для сервера и клиента (на сервере создаётся свой инстанс на каждый запрос, чтобы данные пользователей не смешивались).
В tRPC у инфинити квери есть жёсткое соглашение: поле курсора в инпуте обязано называться cursor, иначе useInfiniteQuery на процедуре просто не появится:
// server/routers/idea.ts export const ideaRouter = router({ // ... list: publicProcedure .input( z.object({ cursor: z.number().default(0), limit: z.number().default(10), }), ) .query(async ({ input: { cursor, limit } }) => { const ideasCount = await prisma.idea.count() const ideas = await prisma.idea.findMany({ take: limit, skip: cursor * limit, orderBy: { updatedAt: 'desc' }, }) const nextCursor = ideasCount > (cursor + 1) * limit ? cursor + 1 : undefined return { ideas, nextCursor } }), // ... })
// client/components/idea.tsx const query = trpc.idea.list.useInfiniteQuery( { limit: 10 }, { getNextPageParam: (lastPage) => lastPage.nextCursor }, )
В Point0 курсором может быть любой ключ инпута, на него указывает pageParamFromInput:
// modules/idea.tsx export const ideaListQuery = root.lets .infiniteQuery() .input( z.object({ page: z.number().default(0), limit: z.number().default(10), }), ) .loader(async ({ input: { page, limit } }) => { const ideasCount = await prisma.idea.count() const ideas = await prisma.idea.findMany({ take: limit, skip: page * limit, orderBy: { updatedAt: 'desc' }, }) const nextCursor = ideasCount > (page + 1) * limit ? page + 1 : undefined return { ideas, ideasCount, nextCursor } }) .infiniteQuery({ // сюда идут любые настройки родного useInfiniteQuery, // плюс наш pageParamFromInput — ключ в инпуте // (можно даже вложенный по типу some.thing.deep), // который играет роль pageParam pageParamFromInput: 'page', getNextPageParam: (lastPage) => lastPage.nextCursor, initialPageParam: 0, })
Далее используем обычный useInfiniteQuery из react-query со всеми его fetchNextPage, hasNextPage, isFetchingNextPage:
const query = ideaListQuery.useInfiniteQuery({ limit: 10 }) const ideas = query.data?.pages.flatMap((page) => page.ideas) ?? []
В tRPC индекс — это appRouter. Он собирается руками и типизирован всеми своими процедурами. Обращаясь к одной процедуре, редактор материализует типы всех остальных. Проект растёт, автокомплит и подсказки становятся всё медленнее.
В Point0 индекса в типах нет вообще. Квери и мутации импортируются напрямую, как обычные значения, и типы каждой живут сами по себе. Обращаясь к одной, редактор просчитывает только её. В бенчмарках это видно числами: инкрементальная перепроверка типов в редакторе у Point0 не растёт с проектом — 1.50 s на 4 страницах, 1.59 s на 504. (Бенчмарки чуть устарели, позже будут пересчитаны, но по сути они верные).
Но индекс всё равно нужен не типам, а рантайму. Движок, который обрабатывает запросы, должен знать все поинты проекта. И вот его в Point0 собирать руками не надо, генератор находит поинты статическим анализом кода и пишет индекс-файл сам. В dev-режиме на лету при каждом изменении, и при билде. Выглядит сгенерированное примерно так:
// generated/point0/points.server.ts — сгенерировано, лежит в .gitignore import type { PointsDefinition } from '@point0/core' import { root as root_0 } from '../../lib/root.js' import { ideaListPage as ideaListPage_1 } from '../../pages/idea-list.js' import { ideaViewPage as ideaViewPage_2 } from '../../pages/idea-view.js' import { ideaViewQuery as ideaViewQuery_3, ideaUpdateMutation as ideaUpdateMutation_4, } from '../../modules/idea.js' export default [ root_0, ideaListPage_1, ideaViewPage_2, ideaViewQuery_3, ideaUpdateMutation_4, ] as PointsDefinition<...>
Этот массив прокидывается в движок в src/engine.ts — и всё, движок знает проект. Клиентский вариант того же файла генерируется с динамическими импортами, чтобы страницы попадали в бандл отдельными ленивыми чанками. Это прописывается один раз, да и в целом при bun create point0-app уже прописано, и потом не меняется:
// src/engine.ts import { Engine } from '@point0/engine' import { clientEnvKeys } from './client-shape' export const engine = Engine.create({ file: import.meta.url, ssr: true, pointsGlob: '**/*.{ts,tsx,mdx}', server: { scope: 'root', entry: { main: './index.server.ts' }, points: async () => await import('./generated/point0/points.server'), generate: { points: './generated/point0/points.server.ts' }, outdir: '../dist/server', }, client: { scope: 'root', indexHtml: './index.html', app: async () => await import('./app.client'), points: async () => await import('./generated/point0/points.client'), generate: { points: './generated/point0/points.client.ts', routes: './generated/point0/routes.ts', }, publicdir: { source: '../public', outdir: '../dist/client' }, outdir: '../dist/client', }, })
// src/app.server.ts import { engine } from './engine' // раздаём наши поинты в качестве обычных эндпоинтов await engine.serve() // можем дописать любой другой серверный код
Итого: в tRPC индекс собираете вы, и он тормозит редактор. В Point0 индекс собирает генератор, и он никого не тормозит, да и почти нигде кроме сетапа не используется.
Посмотрите ещё раз на объявление ideaViewQuery. Там импортирована prisma и написан запрос к базе, и этот же код используется в клиентском компоненте. Как серверный код не оказался в бандле?
В tRPC эта проблема решена дисциплиной. Серверный код живёт в серверных файлах, клиент импортирует только type AppRouter, и вы аккуратно следите, чтобы через живой импорт с сервера ничего не притекло.
В Point0 за это отвечает компилятор. Из клиентского бандла он вырезает серверный код, из серверного — клиентский, а осиротевшие импорты удаляет. Посмотреть результат можно командой:
point0 compile src/modules/idea.tsx --side client
// ваш оригинальный код import { root } from '@/lib/root' import { prisma } from '@/lib/prisma' import * as z from 'zod' export const ideaViewQuery = root.lets .query() .input(z.object({ id: z.string() })) .loader(async ({ input }) => { const idea = await prisma.idea.findUniqueOrThrow({ where: { id: input.id }, }) return { idea } }) .query()
// вывод команды point0 compile src/modules/idea.tsx --side client // это же и есть код, который доедет до клиентского бандла import { root } from '@/lib/root' // prisma и zod вырезались сами — вместе с телом лоадера и схемой export const ideaViewQuery = root // нотация .lets() сама заменяется на подробную, где имя квери берётся из названия переменной .lets('query', 'ideaView') .input() .loader() .query()
Вырезался не только лоадер, но и схема инпута. Валидация живёт на сервере, и zod-схемы в клиентский бандл не едут вообще. Так же вырезаются и остальные серверные методы: .ctx(), .middleware(), схемы экшенов. Клиент знает только имя и тип поинта — этого достаточно, чтобы отправить запрос на сервер. Заодно видно, во что развернулась короткая нотация: root.lets.query() — это сахар, компилятор берёт имя переменной ideaViewQuery, отрезает суффикс типа и получает имя поинта ideaView. Оно ещё сыграет роль в разделе про урлы.
Компилятор существует в трёх форматах: bun-плагин, vite-плагин и babel-плагин, под капотом один и тот же код. Результаты кешируются на диске. Первый (самый первый и единственный) запуск проекта чуть дольше, дальше всегда быстро.
Квери и мутации можно объявлять прямо в файле страницы, рядом с формой, которая их вызывает. React Fast Refresh хочет, чтобы из файла экспортировались только компоненты, иначе правка перезагружает страницу целиком вместо горячей замены.
Мы бандлеры перехитрили. В dev-режиме компилятор дописывает каждому поинту хвост:
export const ideaUpdateMutation = root.lets .mutation() .input(/* ... */) .loader(/* ... */) .mutation() ._tail(() => null) // дописывает компилятор, только в dev
Сам ideaUpdateMutation — это и есть функция, возвращённая из ._tail(() => null), так что и bun, и vite считают экспорт компонентом, и Fast Refresh спокойно работает. А мы к поинту напрямую никогда не обращаемся, мы обращаемся только к его методам, и они все на месте. Мы можем по сути весь проект в одном файле написать и иметь HMR за 15 мс (медиана из бенчмарков).
Складывать всё в один файл никто не заставляет. Point0 вообще не навязывает структуру папок. Хотите — объявляйте поинты мутаций и кверей отдельно, или все в одной папке, или разделяйте по папкам модулей. Разница в том, что здесь это выбор, а не ограничение инструмента.
Так как Point0 это полноценный фреймворк, конечно, он может и страницы, и лэйауты объявлять. И всё это тоже поинты. Квери можно вшить в страницу Point0 через .with() — и это тот же самый инстанс квери с тем же кешем:
import { ideaViewQuery } from '@/modules/idea' export const ideaPage = root.lets .page('/ideas/:id') // мапим типизированные параметры роута на инпут квери .with(ideaViewQuery, ({ params }) => ({ id: params.id })) .head(({ data: { idea } }) => idea.title) .page(({ data: { idea } }) => ( <article> <h1>{idea.title}</h1> <p>{idea.content}</p> </article> ))
В .page() данные уже загружены: состояния загрузки и ошибки рисуются сами, их вид один раз задан в корневом поинте. Или может быть переопределён для каждой отдельной страницы. И одновременно эта же ideaViewQuery.useQuery({ id }) работает в любом компоненте — кеш один, лишний запрос на сервер не уйдёт.
Кто настраивал SSR-гидрацию react-query поверх tRPC, знает эту обвязку наизусть:
// tRPC + Next: prefetch на сервере, dehydrate, прокинуть, hydrate export async function getServerSideProps(ctx) { const helpers = createServerSideHelpers({ router: appRouter, ctx: await createContext(), transformer: superjson, // тот же, что на клиенте, иначе всё развалится }) await helpers.idea.view.prefetch({ id: ctx.params.id }) return { props: { trpcState: helpers.dehydrate(), id: ctx.params.id }, } }
И так на каждой странице, где нужны данные при первом рендере.
В Point0 этого слоя нет совсем. Фреймворк рендерит страницу на сервере, сам видит, какие квери ей нужны, включая объявленные через .with() и внутри компонентов, сам их загружает, кладёт дегидрированный кеш квери клиента в HTML, а на клиенте сам его гидрирует. А если выключите SSR, код не изменится ни на строчку: те же квери просто уедут с клиента. И даже со включённым SSR при переходах по страницам просто будут дозапрашиваться нужные данные и JS-чанки.
Кстати, лоадер страницы — тоже квери. Страница с .loader() сама умеет ideaPage.useQuery({ id }), ideaPage.prefetchQuery({ id }) и всё остальное. Всё поинты, всё react-query:
import { ideaViewQuery } from '@/modules/idea' export const ideaPage = root.lets .page('/ideas/:id') .loader(async ({ params }) => { const idea = await prisma.idea.findUniqueOrThrow({ where: { id: params.id }, }) return { idea } }) .head(({ data: { idea } }) => idea.title) .page(({ data: { idea } }) => ( <article> <h1>{idea.title}</h1> <p>{idea.content}</p> </article> ))
Загрузка файлов — место, где tRPC v11 честно упирается в свой формат. FormData принять можно, но типизация инпута на этом заканчивается:
// tRPC v11 upload: publicProcedure .input(z.instanceof(FormData)) .mutation(async ({ input }) => { const title = input.get('title') // FormDataEntryValue | null const image = input.get('image') // FormDataEntryValue | null // дальше — руками: проверить, скастовать, провалидировать }),
Поля больше не описаны схемой — input.get() возвращает FormDataEntryValue | null, и валидацию вы собираете сами (либо тащите zod-form-data и переписываете схему в её стиле).
В Point0 файл — это обычное поле обычного типизированного инпута:
export const ideaCreateMutation = root.lets .mutation() .input( z.object({ title: z.string().min(1), content: z.string().min(1), image: z.file().optional(), // вот он, файл }), ) .loader(async ({ input }) => { // input.image на сервере — обычный File const imageBase64 = input.image ? Buffer.from(await input.image.arrayBuffer()).toString('base64') : undefined const idea = await prisma.idea.create({ data: { title: input.title, content: input.content, image: imageBase64 }, }) return { idea } }) .mutation()
На клиенте File кладётся в инпут как есть:
const mutation = ideaCreateMutation.useMutation() await mutation.mutateAsync({ title, content, image: fileInput.files?.[0], // просто File из инпута })
FormData под капотом фреймворк соберёт и разберёт сам: остальные поля провалидируются схемой как обычно, файл доедет файлом. Запросы сами под капотом определяют, когда отправлять в формате FormData, а когда в JSON, в зависимости от наличия в данных Blob или File. И все кастомные трансформеры на это точно так же накладываются. Просто все данные будут приведены к плоской структуре для передачи, но вы этого даже не заметите, всё делается под капотом.
Запросы tRPC уезжают на один маунт с именем процедуры в пути и инпутом, закодированным в query string, а батч-линк дополнительно склеивает несколько процедур в один запрос:
GET /api/trpc/idea.view,idea.list?batch=1&input=%7B%220%22%3A%7B%22id%22...
В Point0 у каждой квери и мутации свой стабильный урл — из имени поинта в кебаб-кейсе. Запрос в мутацию всегда POST, а в квери GET при условии, что длина урла не превышает установленного значения, иначе тоже отправится через POST:
GET /_point0/root/query/idea-view ← ideaViewQuery GET /_point0/root/query/idea-list ← ideaListQuery POST /_point0/root/mutation/idea-update ← ideaUpdateMutation
Помните, компилятор вывел имя ideaView из имени переменной? Вот здесь оно и работает. А раз у всего есть обычные урлы и схемы, из них автоматически собирается полная OpenAPI-спека через пакет @point0/openapi, смотреть можно в Scalar или Swagger UI. Каждая квери, мутация и экшен попадают туда сами, а можно и отфильтровать.
У tRPC всё является процедурами. Как только нужен эндпоинт с конкретным методом и путём — например вебхук Stripe, колбэк OAuth, интеграция с чужой системой — вы выходите из tRPC и пишете обычный route handler рядом (или подключаете trpc-to-openapi).
В Point0 для этого есть вид поинтов «экшен», с полным контролем над методом и путём:
export const stripeWebhookAction = root.lets .action('POST', '/api/webhooks/stripe') .loader(async ({ request }) => { const event = await stripe.webhooks.constructEvent( await request.original.text(), request.headers['stripe-signature'], process.env.STRIPE_WEBHOOK_SECRET, ) await handleStripeEvent(event) return { received: true } }) .action()
Экшену можно объявить схемы всего, из чего состоит HTTP-запрос: параметров пути, сёрча, хедеров, боди:
export const myTestAction = root.lets .action('POST', '/api/my-test/:id') .params(z.object({ id: z.coerce.number().min(1) })) .headers(z.object({ x: z.string().min(1) })) .search(z.object({ y: z.string().min(1) })) .body(z.object({ b: z.number().min(1) })) .action(({ params, headers, search, body }) => { return { params, headers, search, body } })
Если объявлена схема боди, фреймворк сам прочитает и распарсит его как json/formData, сохранив оригинал в request.rawBody — для проверки сигнатур вебхуков. Если не объявлять, сможете прочитать боди когда вам угодно и как вам угодно.
Экшен — единственный поинт, который можно закрыть методом: .query(), .mutation() или .infiniteQuery(). И тогда эндпоинт с кастомным путём становится полноценной react-query квери или мутацией на клиенте:
export const ideaUpdateAction = root.lets .action('PUT', '/api/ideas/:id') .body( z.object({ title: z.string().min(1), content: z.string().min(1), }), ) .loader(async ({ params: { id }, body: { title, content } }) => { const idea = await prisma.idea.update({ where: { id }, data: { title, content }, }) return { idea } }) .mutation() // теперь это мутация
const mutation = ideaUpdateAction.useMutation() await mutation.mutateAsync({ // инпут экшена не плоский, а разложен по частям запроса params: { id: idea.id }, body: { title, content }, })
Один поинт — и красивый PUT /api/ideas/:id для внешнего мира, и типизированная мутация со всем кешированием.
В tRPC такого быть не может, потому что это библиотека. А в Point0 лоадер умеет возвращать не только данные, но и реакт-элементы. Элемент в Point0 — это просто данные, такое же поле в ответе, как число или строка. Возвращать их можно откуда угодно, где есть лоадер: из страниц, компонентов, кверей, мутаций.
Элементов ровно два вида, и различаются они тем, чем вы их объявили:
обычная функция-компонент — это серверный компонент. Point0 вызывает его на сервере (можно async), и на клиент едет только его отрендеренная разметка. Сам код и всё, что он импортировал, в браузер не попадают.
компонент-поинт — это интерактивный остров. Он едет ссылкой (своим именем) и пропсами-данными, а на клиенте оживает: стейт, хуки, обработчики.
Разрешение на элементы включается один раз в корневом поинте — чтобы они не протекали в данные случайно:
export const root = Point0.lets .root() .rsc({ depth: 1 }) // элементы разрешены в полях первого уровня .root()
Теперь пример, где в одном лоадере есть и то, и другое. Страница идеи: контент — тяжёлый markdown, лайк — живая кнопка.
Сначала остров. Это обычный компонент-поинт, в своём файле — тогда он поедет отдельным ленивым чанком:
// modules/idea-like.tsx import { root } from '@/lib/root' import { ideaLikeMutation } from '@/modules/idea' import { useState } from 'react' export const IdeaLikeButton = root.lets .component<{ id: string; likes: number }>() .component(({ props }) => { const [likes, setLikes] = useState(props.likes) const mutation = ideaLikeMutation.useMutation() return ( <button disabled={mutation.isPending} onClick={async () => { await mutation.mutateAsync({ id: props.id }) setLikes(likes + 1) }} > ❤️ {likes} </button> ) })
Теперь серверный компонент — тоже в своём файле. Это обычная функция, никаких поинтов, можно async:
// modules/idea-content.tsx import { markdownToHtml } from 'heavy-markdown-lib' // тяжёлая либа export const IdeaContent = async ({ markdown }: { markdown: string }) => { const html = await markdownToHtml(markdown) return <article dangerouslySetInnerHTML={{ __html: html }} /> }
И страница, которая собирает из них ответ:
// pages/idea-view.tsx import { root } from '@/lib/root' import { prisma } from '@/lib/prisma' import { IdeaContent } from '@/modules/idea-content' import { IdeaLikeButton } from '@/modules/idea-like' export const ideaPage = root.lets .page('/ideas/:id') .loader(async ({ params }) => { const idea = await prisma.idea.findUniqueOrThrow({ where: { id: params.id }, }) return { title: idea.title, // обычные данные, как всегда content: <IdeaContent markdown={idea.content} />, // серверный компонент like: <IdeaLikeButton id={idea.id} likes={idea.likes} />, // остров } }) .page(({ data }) => ( <main> <h1>{data.title}</h1> {data.content} {data.like} </main> ))
Страница в .page() просто раскладывает data по местам и вообще не знает, что там элементы. А вот что реально доехало до браузера в ответе:
{ "title": "Фреймворк на Bun", // серверный компонент уже отрендерился — едет только разметка "content": { "__p0e": { "t": "article", "p": { "dangerouslySetInnerHTML": { "__html": "<p>…</p>" } } } }, // остров едет ссылкой на компонент-поинт и своими пропсами "like": { "__p0e": { "t": { "c": "IdeaLikeButton" }, "p": { "id": "42", "likes": 7 } } } }
IdeaContent используется только внутри лоадера — значит его импорт компилятор из клиентского бандла вырежет, вместе с ним уедет и heavy-markdown-lib. А IdeaLikeButton лежит в своём файле, поэтому в бандл страницы не попадает: его чанк скачается только когда в ответе придёт ссылка на него.
Никаких 'use client', никакого второго графа модулей и отдельного протокола: элемент становится данными. Поэтому работает везде, где работают данные, в том числе в мутациях — сервер может ответить сразу отрендеренным куском интерфейса:
export const commentAddMutation = root.lets .mutation() .input(z.object({ ideaId: z.string(), text: z.string().min(1) })) .loader(async ({ input }) => { const comment = await prisma.comment.create({ data: input }) return { comment: <Comment comment={comment} /> } }) .mutation()
const mutation = commentAddMutation.useMutation() // mutation.data.comment — живой элемент, рендерим как есть return <section id="comments">{mutation.data?.comment}</section>
Есть ещё defer, стриминг медленных кусков в тот же ответ, промисы в пропсах островов. Но это всё тема отдельной статьи, но уже есть страница RSC в документации.
Чтобы сравнение оставалось честным:
Сабскрипшенов. У tRPC есть подписки через SSE и WebSocket. В Point0 реалтайм-поинты в активной разработке, но ещё не готовы.
Батчинга запросов. httpBatchLink склеивает несколько кверей в один HTTP-запрос. Батчинг в планах, просто ещё не успел.
Клиента для чужого приложения. Тип AppRouter из tRPC можно заимпортировать в соседний репозиторий и получить типизированный клиент. Поинты Point0 импортируются напрямую в рамках своей кодовой базы, клиентов при этом может быть несколько: сайт, админка, Expo-приложение, у каждого свой бандл. А для внешних потребителей используется OpenAPI-спека. Но и Point0 задумывался как инструмент для фулстеков, которые весь свой код сами себе и напишут, и не будут его развозить по репозиториям. В то же время организовать много репозиториев в Point0 всё равно можно, но это другая история.
Всё показанное выше не библиотека, прикрученная к Next. Это фундамент фреймворка, в котором из тех же поинтов сделано вообще всё: страницы, лэйауты и компоненты со своими лоадерами, провайдеры, роутер с типизированной навигацией, head, SSR, RSC, MDX, ассеты, env-переменные, OpenAPI, MCP-серверы для агентов, сборка.
Если свести статью к одной таблице:
tRPC | Point0 | |
|---|---|---|
Под капотом | react-query | react-query |
Схемы | zod и другие | zod и другие |
Индекс эндпоинтов |
| генератор, сам |
Типы на клиенте | тип всего роутера | тип одной квери |
Серверный код рядом с клиентским | нельзя, разносить по файлам | можно, режет компилятор |
Квери в странице + SSR-гидрация | обвязка на каждой странице | из коробки |
Файл в мутации | FormData без типов | типизированное поле |
Урлы | один маунт, инпут в query string | стабильный урл у каждого поинта |
Кастомный метод и путь | выход из tRPC | экшен, работает как квери/мутация |
Элементы в ответе | нет, только данные | серверные компоненты и острова |
Батчинг | есть | в планах |
Сабскрипшены | есть | в планах |
Фреймворк вокруг | нужен отдельный | это он и есть |
Если вам нравится tRPC, то по духу здесь всё родное: та же react-query, те же схемы, те же сквозные типы без кодогенерации. Просто из этого сделан не слой API поверх чужого фреймворка, а фреймворк целиком.
Спасибо, что дочитали. Поддержите меня, пожалуйста:
Поставьте звезду на гитхабе
Провзаимодействуйте с тредом по этой статье в Twitter (X): https://x.com/s_1gr14/status/2077297754171187628?s=20
Пролайкайте видео и подпишитесь на канал в Youtube: https://www.youtube.com/watch?v=0CT9vBzywyg
Вступайте в сообщество. Телеграм — канал и чат (русский), дискорд — сервер (английский). Отвечаю оперативно.
Документация: https://1gr14.dev/point0, полный разбор фреймворка на хабре — большая статья.
Всем спасибо!