Point0by 1gr14
  • Menu
    • GitHub
  • Introduction
    • Overview
    • Getting Started
    • Full Overview
    • Benchmarks
    • Points
  • Points
    • Page
    • Layout
    • Component
    • Provider
    • Mountable
    • Query
    • Infinite Query
    • Mutation
    • Action
    • Root
    • Base
    • Plugin
  • Methods
    • Validation
    • Loader
    • Context
    • Middleware
    • Loading & error
    • .with
    • Mapper
    • Transformer
    • Stage Methods
  • Core
    • Navigation
    • SSR
    • RSC
    • Request
    • Response
    • Error handling
    • Env
    • Head
    • MDX
    • Assets
    • File upload
    • OpenAPI
    • Query client
    • Events
    • Infer
  • Engine
    • Engine Config
    • Engine Runtime
    • CLI
    • Dev
    • Build
    • Compiler
    • Generator
    • Project MCP
    • Docs MCP
    • Importer
    • Public dir
    • Testing
    • Deploy
    • Bun or Vite
  • Extra
    • SsrStore
    • CookieStore
    • Basic Auth
    • CORS
    • Cache-Control
    • Compress
  • Examples
    • Basic
    • Vite
    • Better Auth
    • Capacitor
    • Expo
  • 1gr14
    • Home
    • Start0
    • Support
    • Author
  • Community
    • Discord
    • Telegram
  • Open Source
    • Point0
    • Route0
    • Error0
    • Flat
  • Account
    • Sign In
    • Sign Up
Point0by 1gr14
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 😎

RSC

  • Category: Core

On this page

  1. Where elements may appear: .rsc({ depth })
  2. Server components
  3. Interactive islands: component points
  4. Queries and mutations ship elements too
  5. Streaming a slow part: defer, suspend, and promise props
  6. defer — stream slow server markup
  7. Where defer streams
  8. suspend — stream a slow interactive island
  9. Promises as island props
  10. Or split the data into a query, injected with .with
  11. Combining them in one page
  12. What's live where
  13. Elements, or plain data?
  14. The wire format
  15. What goes in loader data
  16. The model's edges, and where it goes next
  17. Where next

A server loader can return React elements — as the whole output, or nested inside the data object next to regular values:

const Hero = async ({ ideasCount }: { ideasCount: number }) => (
  <section>
    <h1>Ideas</h1>
    <p>{ideasCount} ideas so far</p>
  </section>
)

export const HomeCta = root.lets
  .component<{ label: string }>()
  .component(({ props }) => (
    <button onClick={() => signUp()}>{props.label}</button>
  ))

export const homePage = root.lets
  .page('/')
  .loader(async () => {
    const ideasCount = await prisma.idea.count()
    return {
      ideasCount, // plain data, as always
      hero: <Hero ideasCount={ideasCount} />, // server component — renders HERE, never ships to the browser
      cta: <HomeCta label="Join" />, // component point — an interactive island on the client
    }
  })
  .page(({ data }) => (
    <main>
      {data.hero}
      {data.cta}
      <footer>{data.ideasCount}</footer>
    </main>
  ))

This is Point0's take on React Server Components: an element is just data. It travels to the client through the same pipe as every other loader value — the data transformer — and lands in data like everything else. There is no separate protocol, no second module graph, no 'use client' directives. Because it rides the normal data pipe, it works everywhere data works: pages, layouts, queries, mutations, components, providers; SSR and hydration; streamed SSR pushes; client-side refetches — a refetch simply delivers fresh elements.

Where elements may appear: .rsc({ depth })

.rsc({ depth: n }) declares how deep in the output object elements are allowed. It is an explicitness gate — elements never leak into data by accident.

The usual setup is one .rsc({ depth: 1 }) on the root — every point inherits it, no loader declares it again, and that is what every other example on this page assumes:

export const root = Point0.lets
  .root()
  .rsc({ depth: 1 }) // the whole app may return elements in first-level fields
  // …the rest of your root defaults
  .root()

Setting it on a single point works the same way and overrides the inherited value:

// depth 0 (the default): an element as the whole output
.loader(async () => <Hello />)
// elements in first-level fields
.rsc({ depth: 1 })
.loader(async () => ({ hero: <Hero /> }))
// any depth — every object level consumes one, arrays don't consume any
.rsc({ depth: 3 })
.loader(async () => ({
  blocks: { main: { hero: <Hero />, items: [<Row key="1" />] } },
}))

An element deeper than the declared depth fails the loader with an error naming the exact path and the depth to set:

RSC (at hero): root:page:home returned a React element deeper than its rsc
depth allows. Raise it with .rsc({ depth: 1 }) on the point …

Inside an element tree the depth no longer applies — props and children nest freely, including further elements.

.rsc(options) is available on every point type and on root/base/plugin, and merges per key down the chain — the root sets the app default, a point overrides only the keys it names. Its second knob, holeTimeoutMs, bounds the point's defer() holes. Server-and-client — kept on both bundles (isomorphic config).

Server components

A plain function component inside loader data is a server component: Point0 calls it on the server right after the loader resolves (async components are awaited), and only its output — host elements like <section> — travels to the client. Its code never ships to the browser: the loader is server-only, so the component's import is pruned from the client bundle with it.

// Heavy dependencies stay on the server — the client receives plain markup.
import { renderMarkdown } from 'heavy-markdown-lib'

const Article = async ({ slug }: { slug: string }) => {
  const post = await prisma.post.findUniqueOrThrow({ where: { slug } })
  return (
    <article dangerouslySetInnerHTML={{ __html: renderMarkdown(post.body) }} />
  )
}

export const postPage = root.lets
  .page('/posts/:slug')
  .loader(async ({ params }) => <Article slug={params.slug} />)
  .page(({ data }) => <main>{data}</main>)

Server components run as plain function calls — hooks and context are not available (the same rule as React's own server components). A component that needs state or effects belongs on the client: make it a component point.

Event handlers cannot travel over the wire, so a function anywhere in a host element's or island's props — a direct prop or nested inside a prop object or array — fails the loader with the path:

RSC (at onClick): functions cannot travel over the wire (prop "onClick" of
<button>). Event handlers and render props belong inside a component point.

Interactive islands: component points

A component point inside loader data serializes as a reference — its name — and its props as data. Component points are points, so the points collection is the registry: point0 generate lists every component in the client aggregator as a lazy record, and a reference resolves from that collection — in the browser and during SSR alike (the server renders with its own per-request copy of the client points) — starting the record's dynamic import on demand:

// cta.tsx — its own file: no page imports it, so it lands in its own chunk
export const HomeCta = root.lets
  .component<{ label: string }>()
  .component(({ props }) => (
    <button onClick={() => signUp()}>{props.label}</button>
  ))

An island is not an RSC-only thing — it's a perfectly ordinary component point. The same <HomeCta label="Join" /> mounts directly in any page or component markup, exactly as the component page shows; returning it from a loader is just a second way to place the same component. Nothing about it changes between the two uses.

  • Declared in a separate file, the component stays out of every page's chunk — the browser downloads it only when a payload references it.
  • During SSR the server renders the real component, so the first paint is complete HTML. On the client the chunk is fetched before hydration (and production builds inject <link rel="modulepreload"> for every reference in the payload, so the fetch runs in parallel with the entry bundle).
  • After a client-side fetch (a query refetch, a mutation result), the query resolves only when the referenced chunks are warm — an island never flashes a Suspense fallback because its code is still downloading.
  • Props travel through the transformer like any data — including nested elements, so children-style slots work: <Card title="Pro" footer={<Hint />} />.
  • A component point with its own .loader or .sharedInput keeps working: referenced from data, it mounts and runs its own query machinery as usual. During SSR its loader runs server-side and the query ships in the dehydrated state — the reference's input prop travels as data and keys the same query, so hydration finds the result and never refetches. Decoded on the client (a query refetch, a mutation result), it fetches its own loader like any mounted component.
  • To skip server rendering for such an island, set .clientOnly() on the component point itself. (<ClientOnly> inside loader data is rejected — the loader never runs in the browser, so the wrapper has no meaning there.)

Component point names are the reference keys, so they must be unique per scope — point0 generate fails with the two file paths when they collide.

Queries and mutations ship elements too

Elements are a loader feature, not a page feature — any query or mutation returns them the same way, with the same .rsc({ depth }) gate. A query with elements is an ordinary query: call it with useQuery anywhere, inject it with .with, warm it in .onPrefetchPage:

export const promoQuery = root.lets
  .query()
  .loader(async () => {
    const promo = await getActivePromo()
    return {
      banner: <PromoBanner promo={promo} />, // server component — rendered here
      cta: <SignUpCta label={promo.cta} />, // component point — an island
    }
  })
  .query()
// any component, not just a page:
const query = promoQuery.useQuery()
return (
  <aside>
    {query.data?.banner}
    {query.data?.cta}
  </aside>
)

A refetch simply delivers fresh elements — promoQuery.invalidateQuery() runs the loader again and the new tree renders in place. Prefetched on the server (.onPrefetchPage(async () => await promoQuery.prefetchQuery())), the elements render into the SSR html and ship in the dehydrated state — the client hydrates them without a request. Under streamed SSR (suspend: 'server') the pushed query state carries the encoded elements into the stream.

A mutation returning elements turns a write into "the server answers with the rendered result":

export const commentAddMutation = root.lets
  .mutation()
  .use(authorizedOnlyPlugin)
  .input(z.object({ ideaId: z.number(), text: z.string().min(1) }))
  .loader(async ({ ctx, input }) => {
    const comment = await prisma.comment.create({
      data: { ...input, authorId: ctx.me.user.id },
    })
    return { comment: <Comment comment={comment} /> }
  })
  .mutation()
// after mutate(), mutation.data.comment is a live element — render it directly
const mutation = commentAddMutation.useMutation()
return <section id="comments">{mutation.data?.comment}</section>

Element-containing query data opts out of TanStack's deep merge automatically — Point0's default structuralSharing hands such payloads back fresh, so a refetch never tries to merge two element trees.

Streaming a slow part: defer, suspend, and promise props

Point0 awaits every async server component in the output before the payload ships, so one slow <Analytics /> holds back the whole loader output — the SSR shell and the client-fetch response alike. Three tools stream the slow part instead of blocking on it, and the choice between them is simple:

  • defer — for slow server markup (a server component with no query of its own);
  • a suspend: 'server' query — for slow interactive content whose value is a query (an island);
  • a promise as an island prop — for slow interactive content fed an ad-hoc value: hand the still-resolving promise straight to the island's prop and read it there with React's use().

All three ship the shell first and push the rest into the same response, with zero client refetch. Everything you need at the RSC level is here; the full suspend reference (all the modes, positional fallbacks) lives in SSR → the ssr and suspend query options.

The examples below assume .rsc({ depth: 1 }) once on your root — every point inherits it, so you never repeat it.

defer — stream slow server markup

Wrap a slow subtree in defer and the loader returns at once: a hole ships in its place under a Suspense boundary showing the fallback, and the resolved markup is delivered on the same response as it settles.

import { defer } from '@point0/core'

// a plain async server component — its code (the markdown renderer, the db call) never ships
const Article = async ({ slug }: { slug: string }) => {
  const post = await db.post.findUniqueOrThrow({ where: { slug } })
  return (
    <article dangerouslySetInnerHTML={{ __html: renderMarkdown(post.body) }} />
  )
}

export const postPage = root.lets
  .page('/posts/:slug')
  .loader(async ({ params }) => ({
    title: await getTitle(params.slug), // fast — ships in the shell
    article: defer(<Article slug={params.slug} />, <ArticleSkeleton />), // slow — streams in
  }))
  .page(({ data }) => (
    <main>
      <h1>{data.title}</h1>
      {data.article}
    </main>
  ))

Only the rendered host elements of <Article /> travel; the second argument is the fallback shown until they land.

A third argument catches failure — a per-hole error boundary. If the deferred subtree throws, this renders in the hole's place instead of the error reaching the point's .error boundary, so the rest of the page stays untouched:

article: defer(<Article slug={params.slug} />, <ArticleSkeleton />, <ArticleFailed />),

It can be a function of the error instead of static markup, so the fallback can show what actually went wrong:

article: defer(<Article slug={params.slug} />, <ArticleSkeleton />, (error) => (
  <ArticleFailed message={error.message} />
)),

The function runs on the server when the subtree fails, and receives the error already projected for the client through your .errorClass() — public fields in production, the full error in dev, the same ErrorPoint0 instance the point's .error boundary would get. If your server component throws a typed error — throw new AppError('Not found', { status: 404 }) — that error reaches the fallback whole, status and code intact, so you can branch on it. Nothing private leaks even if you render the value or pass it to an island's prop, and a per-hole fallback and a boundary always agree on what the error is.

A failed deferred subtree is easy to miss on the server: the loader already returned its shell, so the failure never becomes a loader error. Point0 emits an rscError event for it — subscribe with .on('rscError', …), or catch it alongside every other failure with .on('error', …):

root.on('error', ({ name, error, meta }) => report(error, { name, ...meta }))

Where defer streams

  • the initial SSR load — the shell ships with the fallback, the subtree pushes into the same HTML as it resolves;
  • client fetches — navigation, mutations, query refetches: the point0 client reads the streamed body incrementally, filling each hole as it lands.

A consumer that can't read a stream — SSG, OpenAPI, a foreign HTTP client — gets a single JSON body where defer degraded to inline: the subtree awaited, the fallback dropped, the same content without progressive delivery. The same degrade applies when the loader sets a non-2xx status: an error response is always a single JSON body, never a framed stream.

While a stream waits on a slow subtree, Point0 writes a no-op heartbeat every 5 seconds (a blank NDJSON line / an empty script in the HTML), so a legitimately slow defer is never killed by an idle-connection reaper — Bun's server default reaps silent responses after 10 seconds, and reverse proxies carry idle windows of their own. The flip side: a subtree that never settles would now hold the response open forever, so every hole carries a deadline — the owner point's .rsc({ holeTimeoutMs }), default 60 seconds, false to disable (set it on the root for an app-wide value). A hole that misses it fails with a POINT0_RSC_HOLE_TIMEOUT error, rendered by the hole's error fallback or its nearest boundary; the subtree's late result is dropped unread.

On the client, a streamed fetch resolves with line 1 immediately and keeps reading in the background until the server delivers every hole — navigating away does not abort it. That is deliberate: the fills land in the query cache (a back-navigation shows the completed content instead of refetching), and the deadline above bounds how long the connection can live.

Streamed bodies are per-request state, so they ship Cache-Control: private, no-store (plus Vary: x-point0-stream) — a CDN never stores or replays one. Cache a read the usual way (cache-control) and its non-streamed variant stays cacheable; a defer in it only ever streams to point0 clients. One infrastructure caveat: a reverse proxy that buffers responses collapses progressive delivery into one late chunk. Point0 sends X-Accel-Buffering: no (honored by nginx-style proxies), but after deploying behind a new proxy or edge, load a page with a defer and watch it stream.

suspend — stream a slow interactive island

defer is for markup. Slow content that must stay interactive gets a suspend: 'server' query instead — it streams the same way but comes alive. A component with its own loader is the natural home:

export const LiveStats = root.lets
  .component()
  .loading(() => <StatsSkeleton />) // positional fallback — declared ABOVE the query
  .loader(async () => ({ count: await db.idea.count() })) // ~2s
  .query({ suspend: 'server' }) // don't hold the shell for it
  .component(({ data }) => {
    const [open, setOpen] = useState(false)
    return (
      <button onClick={() => setOpen(!open)}>
        {data.count} ideas{open && ' — nice'}
      </button>
    )
  })

Drop <LiveStats /> anywhere: the shell ships with its .loading(), the loader streams into the same response, and the button works on the first paint — a live island, not just markup.

Promises as island props

A suspend query streams a query value. When the slow value is ad-hoc — a computation, an aggregation, anything you would not model as a query — hand the still-resolving promise straight to the island's prop:

export const dashboardPage = root.lets
  .page('/dashboard')
  .loader(async () => ({
    user: await getUser(), // fast — ships in the shell
    stats: <Stats slowStats={getSlowStats()} />, // getSlowStats() returns a Promise — NOT awaited
  }))
  .page(({ data }) => (
    <main>
      <h1>{data.user.name}</h1>
      {data.stats}
    </main>
  ))

The island mounts live at once — its buttons work while the value is still in flight — and reads the prop with React 19's use() behind its own Suspense. The prop is declared like any other component prop — the generic on .component<…>() — just typed as a Promise:

import { Suspense, use, useState } from 'react'

const StatsValue = ({ slowStats }: { slowStats: Promise<StatsData> }) => {
  const stats = use(slowStats) // suspends until the value streams in, then re-renders
  return (
    <p>
      {stats.views} views · {stats.likes} likes
    </p>
  )
}

export const Stats = root.lets
  .component<{ slowStats: Promise<StatsData> }>() // declares the outer prop, typed as the promise
  .component(({ props }) => {
    const [period, setPeriod] = useState('week')
    return (
      <section>
        {/* live at once — clickable while the value is still in flight */}
        <button onClick={() => setPeriod(period === 'week' ? 'month' : 'week')}>
          Period: {period}
        </button>
        <Suspense fallback={<p>Loading…</p>}>
          <StatsValue slowStats={props.slowStats} />
        </Suspense>
      </section>
    )
  })

StatsValue is not ceremony around use() — it is the waiting boundary, and you draw it yourself: everything outside the Suspense (the period button) works immediately; the part inside is exactly what waits for the value.

The prop travels as a hole in the payload; the resolved value streams into it on the same response — the SSR document or the client fetch — and use() re-renders just the suspended reader. The island is never remounted: state it accumulated while the value streamed (a toggled filter, a typed input) survives the arrival.

Everything defer says about delivery applies verbatim, because promise props ride the same machinery: streaming on the initial SSR load and on every client fetch, heartbeats, the .rsc({ holeTimeoutMs }) deadline (a promise that misses it rejects with POINT0_RSC_HOLE_TIMEOUT), Cache-Control: private, no-store on framed bodies, and the inline degrade for consumers that can't read a stream — there the promise is awaited on the server and its value rides the payload, still decoding to an (already-resolved) promise, so the island's use() contract holds for every consumer.

Two things differ from defer:

  • The island is live on the first SSR paint. The island itself is a regular reference in the payload — never inside a server-revealed hole — and the promise its use() reads is the client's own, filled by the stream. So the defer-hole limitation (below) does not apply: the island hydrates interactive, whether the value arrived before hydration or after.
  • A rejected promise has no per-hole fallback — use() throws the error at render, and the nearest error boundary catches it, exactly like on the client. The error crosses the wire projected through your .errorClass() — public fields in production, everything in dev. Put a boundary inside the island if you want the failure scoped to it.

A promise is accepted only as a prop of a kept element — an island or a host element (anywhere inside the prop's value, arrays and objects included). A promise in a plain data position fails the loader with an error naming the path: data is for values, streaming is for props — await it or move it into an island prop.

Or split the data into a query, injected with .with

Prefer the data in a standalone query and the island in the page? Wire them with .with — the query's input comes from the route, and its suspend: 'server' keeps it streaming instead of blocking the shell:

export const statsQuery = root.lets
  .query()
  .input(z.object({ projectId: z.string() }))
  .loader(async ({ input }) => ({ stats: await countStats(input.projectId) }))
  .query({ suspend: 'server' })

export const projectPage = root.lets
  .page('/projects/:projectId')
  .loading(() => <Spinner />)
  .with(statsQuery, ({ params }) => ({ projectId: params.projectId })) // inject + stream
  .page(({ data: { stats } }) => <ProjectStats stats={stats} />)

The page renders with data once the query resolves; while it streams, the positional .loading() holds its place.

Combining them in one page

Fast data, deferred markup, and a streamed island coexist on one page — each part lands as it is ready:

export const homePage = root.lets
  .page('/')
  .loader(async () => ({
    title: await getTitle(), // fast — in the shell
    article: defer(<Article />, <ArticleSkeleton />), // slow markup — defer
  }))
  .page(({ data }) => (
    <main>
      <h1>{data.title}</h1>
      {data.article}
      <LiveStats />
    </main>
  ))

The shell paints instantly with the title (plus the skeleton and <LiveStats />'s loading state). The article streams in as markup; the stats stream in as a live island. One page, three arrival times, one response.

What's live where

Interactivity only differs on the first SSR paint — every client fetch renders the subtree fresh on the client, so everything is live there.

first SSR paintclient fetch (nav / mutation)
top-level island🟢 live🟢 live
island inside a defer hole🔴 shows, but dead🟢 live
island via suspend: 'server'🟢 live🟢 live
island reading a promise prop🟢 live🟢 live

So the rule is one line: defer for server markup; for interactive islands, a suspend: 'server' query (query values) or a promise prop (ad-hoc values). On the first SSR paint an island revealed inside a defer hole displays but its handlers stay unwired — the browser completes the server-revealed Suspense boundary from the stream and never re-enters the suspended child. A suspend query dodges this because its data rides react-query: when it streams in, the observer re-renders the subscriber and mounts the island fresh. A promise prop dodges it too: the island is an ordinary reference (never inside a server-revealed hole), and the streamed value only settles the promise its use() reads. After the first paint (any client navigation) the limit is gone — a defer hole then renders fresh on the client too.

The full suspend semantics — 'auto' | 'server' | 'client' | true | false, positional fallbacks, point-vs-query level — are in SSR → the ssr and suspend query options.

Elements, or plain data?

Sending plain data and composing in the render stays the default — the page knows what it renders, the types are plain, nothing travels but values:

.loader(async () => ({ stats: await getStats() }))
.page(({ data }) => <StatsCard stats={data.stats} />)

Returning elements buys three things this cannot do:

  1. The server decides the composition at request time. CMS blocks, feature flags, markdown with embedded widgets — the loader assembles a tree the page renders without knowing its shape.
  2. Component code ships per payload, not per page. Code-splitting is by file: a component the page imports statically sits in the page's chunk, but a component point in its own file downloads only when a payload references it — with <link rel="modulepreload"> in production builds. (One file can declare any number of points; the point itself isn't the split unit, the file is.)
  3. Server-only dependencies render markup. A server component runs Prisma, a markdown renderer, a heavy chart layout — and ships only the resulting host elements.

The two element kinds split the same way:

Server component (plain function)Component point (island)
What travelsits rendered output (host elements)a reference (name) + props as data
Its codestays on the serverclient bundle (own chunk if in its own file)
Hooks, state, handlersnot available — one plain function calla full live React component
How it updatesrefetch the loader that produced itre-renders on its own, like any component

When none of the three apply, keep sending data.

The wire format

Elements encode into plain JSON markers inside the regular payload:

{
  "ideasCount": 12,
  "hero": { "__p0e": { "t": "section", "p": { "children": "…" } } }, // host element
  "cta": { "__p0e": { "t": { "c": "HomeCta" }, "p": { "label": "Join" } } }, // component-point reference
}

t is the node type — a host tag string like "section", or 0 (Fragment), 1 (Suspense), 2 (a deferred hole whose id names the fill streamed over __POINT0_PUSH_RSC__ — see defer), 3 (a promise prop — decodes to a promise the island reads with use(); a streaming one carries the fill's id, an inlined one its resolved value as v), or { c: name } (a component-point reference); k is the key, p the props. User data keys that collide with __p0e are $-escaped and restored on decode. Because the codec wraps your transformer, custom types keep working inside element props — a Date in <Hero since={date} /> round-trips like any other Date.

Decoding is one-way by design: the server encodes elements into responses, the SSR-embedded state, and streamed pushes, but never decodes elements from client input — element markers arriving in an input stay inert JSON.

What goes in loader data

Three shapes cover everything a loader can return — a server component, an island, or plain markup:

In loader dataBecomes
a plain function componenta server component — unfolds on the server (async ok, memo unwrapped), only its host output travels
a component pointan island — a reference resolved from the points collection: server-rendered first, live on the client
a host element, Fragment, Suspensekept as-is, its props walked

Everything interactive lives in the island, not the server component — the same feature works, you just declare it in the right place:

You want…Server componentComponent point (island)
event handlers (onClick, …)❌ can't cross the wire✅
hooks, state, effects❌ it's one plain function call✅
React context❌✅
a ref❌ can't cross the wire✅
a code-split chunk❌ React.lazy isn't accepted✅ own file → own lazy chunk
render only on the client—✅ set .clientOnly() on it

So when the loader rejects a function prop, a ref, or React.lazy — or a plain component throws because it called a hook — the fix is always the same: move that piece into a component point.

The rest are genuinely malformed, and the error names the exact path:

  • a class component — server components and component points are both functions; write it as one;
  • a page, layout, or provider point in data — only component points can be referenced;
  • an element or promise inside a Map or Set — the codec walks plain objects and arrays; put element-carrying data in those (an element-free Map/Set passes through to the transformer untouched);
  • a promise in a data position — a promise streams only as a prop of a kept element; await the value in the loader or move it into an island prop;
  • an element deeper than .rsc({ depth: n }) — raise the depth (the error tells you which).

The model's edges, and where it goes next

Point0's RSC is deliberately smaller than Flight, and its edges are explicit:

  • An island inside a defer hole is not interactive on the first SSR paint (the matrix above). This is a known trade of the model, and the answer is not "wait for a fix": slow interactive content goes through suspend: 'server', a promise prop, or a top-level island — first-class patterns, covered on this page. Lifting the limit would take a Flight-style client reconciler, and that machinery is exactly what this model trades away, so don't plan around it changing.
  • Not planned: Flight compatibility, 'use server' actions, a second react-server module graph. Elements ride the normal data pipe — that is the point. Server work goes in loaders, mutations are mutations.

If an edge here costs you something real, open an issue — real usage is what moves these.

Where next

  • Loader — everything a loader can return.
  • Component — component points, the reference targets.
  • Transformer — the data pipe the codec wraps.
  • SSR — streaming, suspense queries, and push-hydration.
PreviousSSRNextRequest

Enjoying Point0?

Give it a star on GitHub, ask questions, and follow what's new

Star Point0

A star on GitHub helps more developers find Point0
Star on GitHub

Start0

Point0 integrated into the Start0 SaaS boilerplate — the best way to use it
Explore Start0

YouTube

Tutorials, walkthroughs, and updates
YouTube

Discord

Questions and chat in English
Join Discord

Telegram

News channel and chat in Russian
ChannelChat

Twitter

Posts and updates
Twitter