1gr14/igrich/
  • Menu
    • Home
    • Start0
    • Support
    • Education
    • Group
    • Blog
    • Author
  • Community
    • Discord
    • Telegram
  • Open Source
    • Point0
    • Route0
    • Error0
    • Flat
    • Agents Party
  • Account
    • Sign In
    • Sign Up
1gr14/igrich/
Building open-source software for the glory of the Lord Jesus Christ ☦️
With love for developers of all backgrounds around the world ❤️
Terms of ServicePrivacy PolicySergei Dmitriev 2026 😎

Point0 — as simple as tRPC, and a whole fullstack framework

Jul 16, 2026#point0#trpc#typescript
Point0 — as simple as tRPC, and a whole fullstack framework
YouTubeVK Видео

I want to compare the Point0 framework with the tRPC library. I am doing it because queries and mutations in Point0 feel a lot like tRPC, and are more convenient in the places where being a framework rather than a library actually buys something:

  • Declaring a query together with its server code right inside a client file, or the other way round
  • Hydrating and dehydrating the query client automatically once SSR is on
  • Sending files from mutations, turning the data into FormData and back on its own
  • Giving every query and mutation its own stable URL
  • Not making the editor crawl as the number of endpoints grows
  • Answering a request with whole server components or interactive islands
  • Controlling what loading states look like, on pages and inside components

Point0 does plenty beyond what this article covers, but that belongs in other articles. Here the focus is the comparison with tRPC. I assume you know tRPC at least roughly, but I still show the tRPC implementation of everything, so the comparison is visible rather than claimed.

A query in tRPC

Let's start with the classic: fetch a record by id. Here is how it looks in tRPC. First the procedure:

// 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 }
    }),
  // every new endpoint gets added here
})

Then it has to be registered in the root router, by hand:

// server/routers/_app.ts
import { router } from '../trpc'
import { ideaRouter } from './idea'

export const appRouter = router({
  idea: ideaRouter,
  // every new router gets appended here
})

export type AppRouter = typeof appRouter

Then you create the client hooks, typed by the type of the whole router:

// utils/trpc.ts
import { createTRPCReact } from '@trpc/react-query'
import type { AppRouter } from '../server/routers/_app'

export const trpc = createTRPCReact<AppRouter>()

And only now can you use it:

// 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>Loading...</div>
  if (result.error) return <div>{result.error.message}</div>
  return <h1>{result.data.idea.title}</h1>
}

A query in Point0

Now Point0. A query is declared in any file of the project, whole, together with its server code:

// modules/idea.tsx
import { root } from '@/lib/root'
import { prisma } from '@/lib/prisma'
import * as z from 'zod'

export const ideaViewQuery = root.lets
  .query()
  // or the alternative spelling: root.lets('query', 'ideaView')
  // every point in Point0 can be declared in the short or the long notation
  .input(z.object({ id: z.string() })) // a schema from any library: zod, valibot, typebox, …
  .loader(async ({ input }) => {
    const idea = await prisma.idea.findUniqueOrThrow({
      where: { id: input.id },
    })
    return { idea }
  })
  .query() // the ordinary useQuery/fetchQuery options go in here

And it is used directly: you import the query itself, not a hook proxy. You do not even have to import it, you can use it right where it was declared, next to the page or the component.

// modules/idea.tsx
export const IdeaView = ({ id }: { id: string }) => {
  const result = ideaViewQuery.useQuery({ id })
  if (result.isLoading) return <div>Loading...</div>
  if (result.error) return <div>{result.error.message}</div>
  return <h1>{result.data.idea.title}</h1>
}

result is the original useQuery object from react-query, nothing wrapped. Every react-query method you are used to lives on the query itself:

ideaViewQuery.useQuery({ id })
ideaViewQuery.fetchQuery({ id })
ideaViewQuery.prefetchQuery({ id })
ideaViewQuery.invalidateQuery({ id })
ideaViewQuery.setQueryData({ id }, ...)
ideaViewQuery.getQueryKey({ id })
// and so on — the whole standard set

The input is always the first argument, and the unique queryKey is built out of it. When the input is optional or absent, you can leave it out entirely.

The difference is already visible: no router file, no AppRouter, no utils/trpc.ts. Declare, import, call. Where the index went and why the server code did not leak into the client are two separate sections below.

A mutation in tRPC

A procedure in the router, useMutation on the client:

// 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 })
    },
  })
  // ...
}

A mutation in Point0

Same schema, same loader, except this is a point of its own, self-sufficient for the client and for the server alike.

// 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}>Save</button>
    </form>
  )
}

Methods like ideaViewQuery.setQueryData() and invalidateQuery() work anywhere: inside a mutation, in a handler, outside a component. The query client in Point0 is shared between server and client (on the server a fresh instance is created per request, so one user's data never mixes with another's).

An infinite query in tRPC

tRPC's infinite query comes with a hard convention: the cursor field in the input must be named cursor, otherwise useInfiniteQuery simply does not appear on the procedure:

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

An infinite query in Point0

In Point0 the cursor can be any key of the input, and pageParamFromInput points at it:

// 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({
    // any option of the native useInfiniteQuery goes here,
    // plus our pageParamFromInput — the key in the input
    // (a nested one like some.thing.deep works too)
    // that plays the role of pageParam
    pageParamFromInput: 'page',
    getNextPageParam: (lastPage) => lastPage.nextCursor,
    initialPageParam: 0,
  })

From there it is the ordinary useInfiniteQuery from react-query, with all of its fetchNextPage, hasNextPage, isFetchingNextPage:

const query = ideaListQuery.useInfiniteQuery({ limit: 10 })
const ideas = query.data?.pages.flatMap((page) => page.ideas) ?? []

Where the index went

In tRPC the index is appRouter. You assemble it by hand and it is typed by every procedure in it. Touch one procedure and the editor materializes the types of all the others. The project grows, autocomplete and hints get slower.

In Point0 there is no index in the types at all. Queries and mutations are imported directly, like ordinary values, and each one's types stand on their own. Touch one and the editor computes only that one. The benchmarks show it in numbers: the editor's incremental re-check does not grow with the project — 1.50 s at 4 pages, 1.59 s at 504. (The benchmarks are slightly out of date and will be re-run, but they are true in substance.)

The index is still needed, just by the runtime rather than by the types. The engine that serves requests has to know every point in the project. That one you do not assemble by hand: a generator finds the points by static analysis and writes the index file itself. On the fly on every change in dev, and from scratch on build. What it writes looks roughly like this:

// generated/point0/points.server.ts — generated, and gitignored
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<...>

That array is handed to the engine in src/engine.ts, and with it the engine knows the project. The client-side twin of the same file is generated with dynamic imports, so pages land in the bundle as separate lazy chunks. It is written once, and bun create point0-app has already written it for you, and after that it does not change:

// 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'

// serve our points as ordinary endpoints
await engine.serve()

// and any other server code you like goes here

So: in tRPC you assemble the index and it slows your editor down. In Point0 the generator assembles it, it slows nobody down, and outside the setup you barely ever look at it.

Server and client code in one file

Look at the declaration of ideaViewQuery again. It imports prisma and writes a database query, and the same file is used by a client component. How did the server code stay out of the bundle?

In tRPC that problem is solved by discipline. Server code lives in server files, the client imports only type AppRouter, and you keep an eye out so nothing flows in through a live import from the server.

In Point0 the compiler is responsible for it. It cuts server code out of the client bundle and client code out of the server one, and deletes the imports that are orphaned as a result. You can look at what came out:

point0 compile src/modules/idea.tsx --side client
// your original code
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()
// the output of point0 compile src/modules/idea.tsx --side client
// this is exactly the code that reaches the client bundle
import { root } from '@/lib/root'
// prisma and zod dropped out on their own — with the loader body and the schema

export const ideaViewQuery = root
  // the .lets notation expands itself, and the query's name comes from the variable name
  .lets('query', 'ideaView')
  .input()
  .loader()
  .query()

It is not only the loader that was cut, the input schema went with it. Validation lives on the server, and zod schemas do not travel to the client bundle at all. The other server-side methods are cut the same way: .ctx(), .middleware(), action schemas. The client knows the point's name and type, and that is all it needs to send a request. You can also see what the short notation expanded into: root.lets.query() is sugar, the compiler takes the variable name ideaViewQuery, strips the type suffix and gets the point name ideaView. That name will matter again in the section about URLs.

The compiler exists in three shapes — a bun plugin, a vite plugin and a babel plugin — with the same code underneath. Results are cached on disk. The very first run of a project takes slightly longer, everything after that is fast.

Everything in one file, with HMR alive

Queries and mutations can be declared right in the page file, next to the form that calls them. React Fast Refresh wants a file to export only components, otherwise an edit reloads the whole page instead of hot-swapping.

We outsmarted the bundlers. In dev the compiler appends a tail to every point:

export const ideaUpdateMutation = root.lets
  .mutation()
  .input(/* ... */)
  .loader(/* ... */)
  .mutation()
  ._tail(() => null) // appended by the compiler, in dev only

ideaUpdateMutation IS the function returned from ._tail(() => null), so both bun and vite consider the export a component and Fast Refresh keeps working. And we never touch the point directly, we only ever call its methods, all of which are still there. You can basically write the whole project in one file and still have 15 ms HMR (the median from the benchmarks).

Nobody makes you put everything in one file. Point0 imposes no folder structure at all. Declare mutation and query points separately, or all in one folder, or split them by module folders. The difference is that here it is your choice rather than the tool's constraint.

Queries inside pages, and hydration out of the box

Point0 is a full framework, so of course it declares pages and layouts too. And those are points as well. A query is embedded into a page with .with(), and it is the same query instance with the same cache:

import { ideaViewQuery } from '@/modules/idea'

export const ideaPage = root.lets
  .page('/ideas/:id')
  // map the typed route params onto the query's input
  .with(ideaViewQuery, ({ params }) => ({ id: params.id }))
  .head(({ data: { idea } }) => idea.title)
  .page(({ data: { idea } }) => (
    <article>
      <h1>{idea.title}</h1>
      <p>{idea.content}</p>
    </article>
  ))

Inside .page() the data is already loaded: the loading and error states render themselves, and what they look like is declared once on the root point. Or overridden per page. At the same time that same ideaViewQuery.useQuery({ id }) works in any component — one cache, no extra request to the server.

Anyone who has wired react-query SSR hydration on top of tRPC knows this boilerplate by heart:

// tRPC + Next: prefetch on the server, dehydrate, pass it down, hydrate
export async function getServerSideProps(ctx) {
  const helpers = createServerSideHelpers({
    router: appRouter,
    ctx: await createContext(),
    transformer: superjson, // the same one as on the client, or it all falls apart
  })
  await helpers.idea.view.prefetch({ id: ctx.params.id })
  return {
    props: { trpcState: helpers.dehydrate(), id: ctx.params.id },
  }
}

And so on every page that needs data on the first render.

In Point0 that layer does not exist. The framework renders the page on the server, sees for itself which queries it needs — including the ones declared with .with() and the ones inside components — fetches them, puts the dehydrated query-client cache into the HTML, and hydrates it on the client. And if you turn SSR off, not a line of your code changes: the same queries simply run from the client. Even with SSR on, navigating between pages just fetches the data and the JS chunks that are actually needed.

The page loader, by the way, is a query too. A page with a .loader() can do ideaPage.useQuery({ id }), ideaPage.prefetchQuery({ id }) and all the rest. Points all the way down, react-query all the way down:

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>
  ))

A file in a mutation is just an input field

File upload is where tRPC v11 honestly hits the edge of its format. It can accept FormData, but the input's typing ends right there:

// 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
    // from here on it is manual: check, cast, validate
  }),

The fields are no longer described by a schema — input.get() returns FormDataEntryValue | null, and you assemble the validation yourself (or pull in zod-form-data and rewrite the schema in its style).

In Point0 a file is an ordinary field of an ordinary typed input:

export const ideaCreateMutation = root.lets
  .mutation()
  .input(
    z.object({
      title: z.string().min(1),
      content: z.string().min(1),
      image: z.file().optional(), // there it is, the file
    }),
  )
  .loader(async ({ input }) => {
    // on the server input.image is an ordinary 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()

On the client the File goes into the input as is:

const mutation = ideaCreateMutation.useMutation()

await mutation.mutateAsync({
  title,
  content,
  image: fileInput.files?.[0], // just the File from the input
})

The framework assembles and disassembles the FormData itself: the other fields validate against the schema as usual, the file arrives as a file. A request decides on its own whether to go as FormData or as JSON, based on whether the data holds a Blob or a File. Custom transformers still apply on top. The data is flattened for transport, and you will not notice any of it.

Stable URLs

tRPC requests travel to a single mount with the procedure name in the path and the input encoded into the query string, and the batch link additionally glues several procedures into one request:

GET /api/trpc/idea.view,idea.list?batch=1&input=%7B%220%22%3A%7B%22id%22...

In Point0 every query and mutation has its own stable URL, built from the point's name in kebab-case. A mutation request is always POST; a query goes GET as long as the URL stays under the configured length, and over it the request goes POST instead:

GET  /_point0/root/query/idea-view      ← ideaViewQuery
GET  /_point0/root/query/idea-list      ← ideaListQuery
POST /_point0/root/mutation/idea-update ← ideaUpdateMutation

Remember the compiler deriving the name ideaView from the variable name? This is where it pays off. And since everything has a normal URL and schemas, a full OpenAPI spec is assembled from them automatically by the @point0/openapi package, viewable in Scalar or Swagger UI. Every query, mutation and action lands there by itself, and you can filter them if you want.

Actions: when you need a real endpoint

In tRPC everything is a procedure. The moment you need an endpoint with a specific method and path — a Stripe webhook, an OAuth callback, an integration with someone else's system — you step out of tRPC and write an ordinary route handler next to it (or bring in trpc-to-openapi).

Point0 has a kind of point for that, the action, with full control over the method and the path:

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()

An action can declare a schema for everything an HTTP request is made of: path params, search, headers, body:

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

With a body schema declared, the framework reads and parses the body as json/formData itself, keeping the original in request.rawBody for webhook signature checks. Without one, you read the body whenever and however you like.

An action is the one point you can close with a method: .query(), .mutation() or .infiniteQuery(). And then an endpoint with a custom path becomes a fully typed react-query query or mutation on the client:

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() // and now it is a mutation
const mutation = ideaUpdateAction.useMutation()

await mutation.mutateAsync({
  // an action's input is not flat, it is split by the parts of the request
  params: { id: idea.id },
  body: { title, content },
})

One point, and you get both a nice PUT /api/ideas/:id for the outside world and a typed mutation with all the caching for yourself.

Server components and interactive islands

tRPC cannot have this, because it is a library. In Point0 a loader can return not only data but React elements. An element in Point0 is just data, a field in the response like a number or a string. You can return one from anywhere that has a loader: pages, components, queries, mutations.

There are exactly two kinds of element, and what tells them apart is how you declared them:

  • an ordinary component function is a server component. Point0 calls it on the server (it may be async), and only its rendered markup travels to the client. Its code, and everything it imported, never reaches the browser.
  • a component point is an interactive island. It travels as a reference (its name) plus its props as data, and comes alive on the client: state, hooks, handlers.

Permission for elements is granted once on the root point, so they cannot leak into data by accident:

export const root = Point0.lets
  .root()
  .rsc({ depth: 1 }) // elements are allowed in first-level fields
  .root()

Now an example with both in one loader. An idea page: the content is heavy markdown, the like button is live.

The island first. It is an ordinary component point, in its own file, so it travels as a separate lazy chunk:

// 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>
    )
  })

Now the server component, also in its own file. It is a plain function, no points involved, and it may be async:

// modules/idea-content.tsx
import { markdownToHtml } from 'heavy-markdown-lib' // a heavy library

export const IdeaContent = async ({ markdown }: { markdown: string }) => {
  const html = await markdownToHtml(markdown)
  return <article dangerouslySetInnerHTML={{ __html: html }} />
}

And the page that assembles the response out of them:

// 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, // ordinary data, as always
      content: <IdeaContent markdown={idea.content} />, // a server component
      like: <IdeaLikeButton id={idea.id} likes={idea.likes} />, // an island
    }
  })
  .page(({ data }) => (
    <main>
      <h1>{data.title}</h1>
      {data.content}
      {data.like}
    </main>
  ))

Inside .page() the page just lays data out and has no idea there are elements in it. And here is what actually reached the browser in the response:

{
  "title": "A framework on Bun",
  // the server component has already rendered — only markup travels
  "content": {
    "__p0e": {
      "t": "article",
      "p": { "dangerouslySetInnerHTML": { "__html": "<p>…</p>" } },
    },
  },
  // the island travels as a reference to a component point, plus its props
  "like": {
    "__p0e": {
      "t": { "c": "IdeaLikeButton" },
      "p": { "id": "42", "likes": 7 },
    },
  },
}

IdeaContent is used only inside the loader, so the compiler cuts its import out of the client bundle, and heavy-markdown-lib goes with it. IdeaLikeButton lives in its own file, so it is not in the page's bundle either: its chunk is downloaded only when a response actually references it.

No 'use client', no second module graph, no separate protocol: the element becomes data. Which is why it works everywhere data works, mutations included — the server can answer with an already rendered piece of interface:

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 is a live element, render it as is
return <section id="comments">{mutation.data?.comment}</section>

There is also defer, streaming the slow pieces into the same response, and promises in island props. That is a subject for a separate article, and there is already an RSC page in the documentation.

Subscriptions: they were on the roadmap, now they are here

When this article was first published Point0 had no subscriptions, and that was a fair point in tRPC's favour. Now there are two kinds of them, and both are declared as the same kind of point as everything else.

A subscription is a generator loader over ordinary HTTP: every yield travels to the client the moment it happens, the transport is NDJSON on a real endpoint, and there is no socket to keep alive.

export const taskProgressSubscription = root.lets
  .subscription()
  .input(z.object({ taskId: z.string() }))
  .loader(async function* ({ input, signal }) {
    for await (const percent of watchProgress(input.taskId, { signal })) {
      yield { percent } // one streamed value per yield
    }
  })
  .subscription()

Sockets are a different layer: a channel, spaces with rooms, and handlers in both directions, over one WebSocket per client application. That is where rooms, presence, admin commands and the multi-process backplane live. They have an article of their own, with five worked examples: realtime in Point0, and a documentation page.

What Point0 still does not have

To keep the comparison honest:

  • Request batching. httpBatchLink glues several queries into one HTTP request. Batching is planned, I just have not got to it.
  • A client for someone else's app. tRPC's AppRouter type can be imported into a neighbouring repository to get a typed client. Point0 points are imported directly within their own codebase, and there can be several clients: the site, the admin panel, an Expo app, each with its own bundle. External consumers get the OpenAPI spec instead. Point0 was designed as a tool for fullstack developers who write all of their own code and do not spread it across repositories. Organising several repositories is still possible, but that is another story.

And on top of that, it is a whole framework

Everything above is not a library bolted onto Next. It is the foundation of a framework in which everything is made of those same points: pages, layouts and components with their own loaders, providers, a router with typed navigation, head, SSR, RSC, MDX, assets, env variables, OpenAPI, MCP servers for agents, the build.

In summary

The whole article as one table:

tRPCPoint0
Underneathreact-queryreact-query
Schemaszod and otherszod and others
Endpoint indexappRouter by handa generator, by itself
Types on the clientthe type of the whole routerthe type of one query
Server code next to client codeno, split it across filesyes, the compiler cuts it
Query in a page + SSR hydrationboilerplate on every pageout of the box
A file in a mutationFormData without typesa typed z.file() field
URLsone mount, input in the query stringa stable URL per point
Custom method and pathstep out of tRPCan action, and it works as a query/mutation
Elements in a responseno, data onlyserver components and islands
Batchingyesplanned
SubscriptionsSSE and WebSocketan HTTP subscription and sockets with rooms
Framework around ityou need a separate onethis is it

If you like tRPC, the spirit here is familiar: the same react-query, the same schemas, the same end-to-end types with no codegen. It is just that what was built out of them is not an API layer on top of somebody else's framework, but the whole framework.

P.S.

Thanks for reading. I would appreciate the support:

  • Star the GitHub repository
  • Interact with the thread about this article on Twitter (X): https://x.com/s_1gr14/status/2077297754171187628?s=20
  • Like the video and subscribe on YouTube: https://www.youtube.com/watch?v=0CT9vBzywyg
  • Join the community. Discord — the server (English), Telegram — the channel and the chat (Russian). I answer quickly.
  • Read the documentation: the framework overview is the full walkthrough of what Point0 is made of.

Thank you all.

  • Point0 documentation
  • GitHub repository
  • Benchmarks against Next.js and TanStack Start
  • The whole documentation in one file, for an AI agent
All articles

Extra

Community

Questions and chat — English on Discord, Russian on Telegram
DiscordTelegram

Social

Videos and posts from around the web
YouTubeTwitter

Private group

A paid community with mentorship, where everyone builds their own IT product
About the group

Start0

The SaaS boilerplate on Point0 — the fastest way to start your own product
Explore Start0

Comments

to leave a comment
No comments yet. Be the first.