Rendering Modes

Furin configures rendering mode at the route level with createRoute(). The page itself still comes from route.page().

Composite React Server Components

RSC is a value returned by a loader, not a fourth rendering mode. Import the explicit API from furin/rsc; no use client or use server directive is required.

tsx
import { createCompositeComponent, CompositeComponent } from "furin/rsc"

const route = createRoute({
  loader: async () => ({
    card: await createCompositeComponent<{
      Action: React.ComponentType<{ productId: string }>
      children?: React.ReactNode
      renderMeta?: (value: { updatedAt: string }) => React.ReactNode
    }>(({ Action, children, renderMeta }) => (
      <article>
        <Action productId="product-1" />
        {renderMeta?.({ updatedAt: "today" })}
        {children}
      </article>
    )),
  }),
})

export default route.page({
  component: ({ card }) => (
    <CompositeComponent
      src={card}
      Action={({ productId }) => <button>Add {productId}</button>}
      renderMeta={({ updatedAt }) => <small>{updatedAt}</small>}
    >
      <p>Client-owned composition</p>
    </CompositeComponent>
  ),
})

The server fragment is encoded as Flight and contributes no application JavaScript. Client interactivity enters through children, component slots, or render props. Arbitrary client imports from the server fragment are intentionally unsupported in composite v1.

Each encoded RSC source has a 4 MiB safety limit in every runtime and build graph. Furin rejects an oversized source before transport; split large content into smaller sources or load the data separately. This limit is not configurable.

The task-manager example includes equivalent ISR routes for comparison: / renders the board list as a conventional client component tree, while /rsc keeps the board markup on the server and hydrates only the create form and delete buttons. Build examples/task-manager in production mode to compare their route chunks; shared React and Furin runtime chunks are intentionally excluded from that comparison.

With two boards in the example database, the production snapshot is:

TransferConventional ISRComposite RSCDifference
Route JavaScript (gzip)1,999 B675 B-66.2%
Initial HTML + route JavaScript (gzip)4,385 B3,526 B-19.6%
SPA data + route JavaScript (gzip)2,456 B2,304 B-6.2%

Flight increases the HTML or navigation payload, so the JavaScript reduction is not a direct total-transfer reduction. The larger runtime benefit is that React does not hydrate the server-owned card markup. Treat these numbers as an example-specific snapshot, not a framework-wide benchmark.

Use import "furin/server-only" or import "furin/client-only" as optional build-time assertions. These imports validate graph boundaries; they do not classify components.

Personalized cached routes

For an SSG or ISR route, keep shared data in loader and request-specific data in requestLoader:

tsx
const route = createRoute({
  mode: "isr",
  loader: async () => ({ catalog: await getPublicCatalog() }),
  requestLoader: ({ cookies }) => ({
    user: getUser(cookies.get("session")),
  }),
})

The component receives requestData as a Promise. Read it below an explicit <Suspense> boundary. Furin caches only the public React 19.2 prerender shell and resumes the private section once per request. Pure static export rejects requestLoader because no request runtime exists.

Public loader functions on these cached routes cannot read request, cookie, or request headers; Furin rejects that access to prevent user-specific data from entering a shared shell. Move all request-specific work to requestLoader.

Mutations remain Elysia/Eden Treaty endpoints. Furin does not expose React Server Functions or accept incoming Flight payloads.

SSR

SSR is the default mode when you omit mode.

src/pages/dashboard/_route.tsx
import { createRoute } from "@teyik0/furin/client"
import { route as rootRoute } from "../root"

export const route = createRoute({
  parent: rootRoute,
  loader: async ({ request }) => {
    const user = await getSession(request)
    return { user }
  },
})

Use SSR for session-dependent pages, personalized data, and anything that must render fresh on every request.

SSR also supports deferred data streaming via defer() and <Await> for slow loader data.

SSG

Set mode: "ssg" on createRoute() to pre-render the page.

src/pages/about.tsx
import { createRoute } from "@teyik0/furin/client"

const route = createRoute({
  mode: "ssg",
})

export default route.page({
  component: () => <div>This page is pre-rendered</div>,
})

For dynamic SSG routes, declare staticParams() on route.page():

src/pages/blog/[slug].tsx
import { t } from "elysia"
import { createRoute } from "@teyik0/furin/client"

const route = createRoute({
  mode: "ssg",
  params: t.Object({
    slug: t.String(),
  }),
})

export default route.page({
  staticParams: async () => {
    const slugs = await db.getAllSlugs()
    return slugs.map((slug) => ({ slug }))
  },
  loader: async ({ params }) => {
    const post = await db.getPost(params.slug)
    return { post }
  },
  component: ({ post }) => <Post post={post} />,
})

ISR

ISR also belongs on createRoute(). Pair mode: "isr" with revalidate.

src/pages/products/[id].tsx
import { t } from "elysia"
import { createRoute } from "@teyik0/furin/client"

const route = createRoute({
  mode: "isr",
  revalidate: 60,
  params: t.Object({
    id: t.String(),
  }),
})

export default route.page({
  loader: async ({ params }) => {
    const product = await db.getProduct(params.id)
    return { product }
  },
  component: ({ product }) => <ProductPage product={product} />,
})

Comparison

ModeConfigured onBest for
ssrcreateRoute() or defaultrequest-time, personalized data
ssgcreateRoute({ mode: "ssg" })content that changes rarely
isrcreateRoute({ mode: "isr", revalidate })pages that can be cached then refreshed periodically

Head Metadata

Page-level head() still lives on route.page().

tsx
export default route.page({
  loader: async ({ params }) => {
    const post = await db.getPost(params.slug)
    return { post }
  },
  head: ({ post }) => ({
    meta: [
      { title: post.title },
      { name: "description", content: post.excerpt },
    ],
  }),
  component: ({ post }) => <Post post={post} />,
})

Comments