javascript

Point0 — простой, как tRPC, полноценный фулстек-фреймворк

  • четверг, 16 июля 2026 г. в 00:00:07
https://habr.com/ru/articles/1059310/

Хочу сравнить фреймворк Point0 и библиотеку tRPC. Делаю это, потому что квери и мутации в Point0 очень похожи по DX на tRPC, но более удобны ввиду того, что всё же Point0 это фреймворк и он может обладать возможностями, которыми библиотека не может:

  • Объявлять квери вместе с серверным кодом прямо в клиентских файлах, или наоборот

  • Автоматически гидрировать и дегидрировать квери клиент при включении SSR

  • В мутациях отправлять файлы автоматически превращая данные в FormData и обратно

  • Иметь стабильные индивидуальные урлы для квери и мутаций

  • Не заставлять зависать редактор кода при увеличении количества эндпоинтов

  • Возвращать в ответах с сервера на клиент целые серверные компоненты или интерактивные острова

  • Управлять внешним видом состояний загрузки на страницах и в компонентах

Кроме того, что описано в этой статье, Point0 может много чего ещё, но об этом в других статьях. Сейчас фокусируемся на сравнении с tRPC. Предполагается, что вы с tRPC более менее знакомы, но ниже я всё равно буду показывать примеры реализации на tRPC, чтобы сравнения были видимыми.

Справка

Квери в tRPC

Начнём с классики: получить запись по 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

Теперь 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. Объявил, импортировал, вызвал. Куда при этом делся индекс и почему серверный код не утёк на клиент, будет в двух отдельных разделах ниже.

Мутации в tRPC

Процедура в роутере, на клиенте 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

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

В 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

В 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-плагин, под капотом один и тот же код. Результаты кешируются на диске. Первый (самый первый и единственный) запуск проекта чуть дольше, дальше всегда быстро.

Всё в одном файле — и HMR живой

Квери и мутации можно объявлять прямо в файле страницы, рядом с формой, которая их вызывает. 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 в документации.

Чего в Point0 пока нет

Чтобы сравнение оставалось честным:

  • Сабскрипшенов. У 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 и другие

Индекс эндпоинтов

appRouter руками

генератор, сам

Типы на клиенте

тип всего роутера

тип одной квери

Серверный код рядом с клиентским

нельзя, разносить по файлам

можно, режет компилятор

Квери в странице + SSR-гидрация

обвязка на каждой странице

из коробки

Файл в мутации

FormData без типов

типизированное поле z.file()

Урлы

один маунт, инпут в query string

стабильный урл у каждого поинта

Кастомный метод и путь

выход из tRPC

экшен, работает как квери/мутация

Элементы в ответе

нет, только данные

серверные компоненты и острова

Батчинг

есть

в планах

Сабскрипшены

есть

в планах

Фреймворк вокруг

нужен отдельный

это он и есть

Если вам нравится tRPC, то по духу здесь всё родное: та же react-query, те же схемы, те же сквозные типы без кодогенерации. Просто из этого сделан не слой API поверх чужого фреймворка, а фреймворк целиком.

P.S.

Спасибо, что дочитали. Поддержите меня, пожалуйста:

Всем спасибо!