GitHub - 1gr14/point0

8 min read Original article ↗

A fullstack TypeScript framework on Bun — your whole app, pages to endpoints, from one typed building block.

Point0

The first fullstack framework on Bun — the scope of Next.js and TanStack Start, the simplicity of tRPC. One typed builder describes every point: pages, layouts, components, providers, queries, mutations, actions. Everything that affects a page lives in the page's builder methods: no hidden config in other files, no folder structure forced on you. The loader is plain react-query under the hood, so pages, layouts, and components become cacheable queries themselves. Server and client code live in the same builder; the compiler strips the loader body and all its imports out of the client bundle. Works with and without SSR. Types aren't generated — it all rides on the builder's generics.

bun create point0-app@latest

Below is the root — the shared setup every point inherits — and five examples built on it. It's a deliberately thin slice: enough to feel how Point0 works without drowning you in features. The framework behind it is much bigger — what it covers is summed up at the end of this page, walked end to end in Full Overview, and covered in depth on each feature's own page — table of contents at the end.

Root point

Every point grows from a root — root. You set the shared things once here: the loading view, the error view, the transformer, the schema helper, and more. Every page and component inherits those, so in the examples below you won't have to think about loading and errors.

import { Point0 } from '@point0/core'

export const root = Point0.lets
  .root()
  // shown while a point's data is loading
  .loading(() => <Spinner />)
  // shown if loading failed
  .error(({ error }) => <ErrorScreen error={error} />)
  .root() // a point ends with the word it started with (.root) — same for all points

A page with a loader

The path, the data, and the markup live in one place. params is typed straight from the route string. The framework renders the loading and error states for you.

import { root } from '@/lib/root'
import { prisma } from '@/lib/prisma'

export const ideaPage = root.lets
  .page('/ideas/:id') // params.id is typed because the route has :id
  .loader(async ({ params }) => {
    const idea = await prisma.idea.findUniqueOrThrow({
      where: { id: params.id },
    })
    return { idea } // whatever the loader returns is typed below
  })
  .head(({ data: { idea } }) => idea.title) // the page's <title>
  .page(({ data: { idea } }) => (
    // in .page() the data is already loaded — otherwise we never reach here,
    // the root's .loading() or .error() shows instead
    <article>
      <h1>{idea.title}</h1>
      <p>{idea.content}</p>
    </article>
  ))

A page with one injected query

When a loader is reused, move it into its own query and inject it into the page with .with(). The query has a single cache — no duplicate requests hit the server.

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() })) // schema via any library: zod, valibot, typebox…
  .loader(async ({ input }) => {
    const idea = await prisma.idea.findUniqueOrThrow({
      where: { id: input.id },
    })
    return { idea }
  })
  .query()

export const ideaPage = root.lets
  .page('/ideas/:id')
  // inject the query and map route params onto its 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>
  ))

You can call the same query like any react-query in any component: ideaViewQuery.useQuery({ id }). The compiler strips the server code out of it on the client.

A page with two injected queries

.with() can be called more than once. The queries load in parallel; the page renders once both are ready. .mapper() folds them into one tidy data before the render.

export const ideaPage = root.lets
  .page('/ideas/:id')
  .with(ideaViewQuery, ({ params }) => ({ id: params.id }))
  .with(ideaBestQuery) // a second query; both load in parallel
  .mapper(({ queries: [view, best] }) => ({
    idea: view.data.idea,
    bestIdea: best.data.bestIdea,
  }))
  .page(({ data: { idea, bestIdea } }) => (
    <article>
      <h1>{idea.title}</h1>
      <aside>Best idea: {bestIdea.title}</aside>
    </article>
  ))

A component with its own loader

More often, different parts of a page need different data. Don't pull everything into the page loader — let a component load its own. A component is a point too: its own loader, its own props, its own loading and error states.

import { root } from '@/lib/root'
import { prisma } from '@/lib/prisma'

export const IdeaBestComponent = root.lets
  .component<{ cta: string }>() // the component's input props type
  .loader(async () => {
    const bestIdea = await prisma.idea.findFirstOrThrow({
      orderBy: { rating: 'desc' },
    })
    return { bestIdea }
  })
  .component(({ data, props }) => (
    <div>
      <h2>Best idea: {data.bestIdea.title}</h2>
      <p>{props.cta}</p>
    </div>
  ))

// use it like any component — short notation (name starts with a capital letter)
export const homePage = root.lets.page('/').page(() => (
  <main>
    <h1>Home</h1>
    <IdeaBestComponent cta="It's on fire!" />
  </main>
))

A page with a mutation

A mutation is a react-query mutation. Declare it anywhere, call it directly by importing the mutation itself. Types don't bloat — the editor stays fast.

import { root } from '@/lib/root'
import { prisma } from '@/lib/prisma'
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()

export const ideaEditPage = root.lets
  .page('/ideas/:id/edit')
  .with(ideaViewQuery, ({ params }) => ({ id: params.id }))
  .page(({ data: { 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>
    )
  })

Client bundle size

  • @point0/core: raw 143.4 KB, gzip 40.9 KB, brotli 36.2 KB
  • @1gr14/route0 (peer): raw 15.0 KB, gzip 4.7 KB, brotli 4.2 KB
  • @1gr14/error0 (optional peer): raw 3.6 KB, gzip 1.4 KB, brotli 1.3 KB
  • @tanstack/react-query (peer): raw 38.2 KB, gzip 15.9 KB, brotli 14.2 KB

The rest of the framework

Five examples and one builder — that's how Point0 feels, not how big it is. The same builder carries a complete framework. Points cover pages, layouts, components, providers, queries, infinite queries, mutations, and actions. Their methods cover validation with any schema library, middleware, context, loading and error states, redirects, and the <head>. Around the points: typed navigation, SSR or a pure client app, file uploads, OpenAPI generation, typed env, assets, MDX, events. And Point0 ships its own engine — a compiler, a dev server, a production build, a CLI, testing helpers, and two MCP servers: one that knows your project, one that knows the docs.

All of that, and the daily loop stays fast — every number below comes from an open benchmark repo (Benchmarks). HMR lands an edit in the DOM in ~15 ms — 3× faster than Next.js, 11× faster than TanStack Start. At 500 pages the production build finishes over 2× faster than both, and the per-edit type-check stays flat where Next.js slows down 2.5×.

That's Point0: the scope of Next.js and TanStack Start, the simplicity of tRPC, a DX no other framework has. Scaffold an app and feel it:

bun create point0-app@latest

Documentation

Full reference at 1gr14.dev/point0.

Introduction

Points

Methods

Core

Engine

Extra

Examples

Community

Questions, bugs, or want to hang with other builders? Join the 1gr14 community — one hub for all our open-source projects, this one included. Get help, share what you built, or just say hi: 1gr14.dev/#community

Contributing

Issues and PRs welcome. See CONTRIBUTING.md and the Code of Conduct. Commits follow Conventional Commits. Security reports: SECURITY.md.

License

MIT


Made by 1gr14, driven by community