@k8ordo/server

Boundaries

Server by default, the browser opted into with 'use client': boundaries are declared in React's own words, and the build checks them. This page covers when a Server Component runs in this mode, what crosses the boundary, reading what only a browser has, and modules that must never reach the client.

Server Components by default

A file with no directive is a Server Component. It runs on the server side only, may be async, and reads its data directly; its code never reaches the browser, only what it rendered.

In this mode a Server Component runs per request, reading the data as it is at that moment.

// src/routes/_data/catalog.server.ts
import 'server-only';

export type Product = { id: number; name: string };

const CATALOG: readonly Product[] = [
  { id: 1, name: 'first product' },
  { id: 2, name: 'second product' },
];

export const listProducts = (): readonly Product[] => CATALOG;
// src/routes/products/page.tsx
import { href } from '@k8ordo/router';

import { listProducts } from '../_data/catalog.server';

export default function ProductsPage() {
  const products = listProducts();
  return (
    <ul>
      {products.map((product) => (
        <li key={product.id}>
          <a href={href('/products/:id', { id: product.id })}>
            {product.name}
          </a>
        </li>
      ))}
    </ul>
  );
}

'use client' opts into the browser

The browser side is opted into with React's own word, 'use client'. A Server Component imports such a component like anything else, and only that component crosses; the page stays on the server. Whatever a 'use client' file imports goes into the client bundle with it.

// src/routes/_parts/counter.tsx
'use client';

import { useState } from 'react';

export function Counter() {
  const [n, setN] = useState(0);
  return (
    <button
      onClick={() => {
        setN(n + 1);
      }}
      type="button"
    >
      {n}
    </button>
  );
}
// src/routes/page.tsx
import { Counter } from './_parts/counter';

export default function HomePage() {
  return (
    <>
      <h1>home</h1>
      <Counter />
    </>
  );
}

A client component is also rendered to HTML once on the server, then hydrated in the browser. One that reads something the server render cannot have — localStorage, say — takes the form in "Components that need a browser" below.

'use server' declares a server function the client may call — a different thing from server-only. Actions & requests

What crosses the boundary

Props from a Server Component to a client component are serialized on the way. Strings, numbers, booleans, null, plain objects and arrays, Date, Map, Set, Promise and JSX — including children a Server Component rendered — cross; functions and class instances do not.

// src/routes/page.tsx
import { Greeting } from './_parts/greeting';

export default function HomePage() {
  return (
    <Greeting renderedAt={new Date()} tags={['rsc', 'boundaries']}>
      <p>rendered on the server</p>
    </Greeting>
  );
}
// src/routes/_parts/greeting.tsx
'use client';

import type { ReactNode } from 'react';

export function Greeting({
  renderedAt,
  tags,
  children,
}: {
  renderedAt: Date;
  tags: string[];
  children: ReactNode;
}) {
  return (
    <section>
      <time dateTime={renderedAt.toISOString()}>
        {renderedAt.toISOString()}
      </time>
      <ul>
        {tags.map((tag) => (
          <li key={tag}>{tag}</li>
        ))}
      </ul>
      {children}
    </section>
  );
}

Passing a function fails that page's render with React's error (Functions cannot be passed directly to Client Components). The exception is a Server Action: a 'use server' function crosses as a reference.

Every piece of text on this site is a message() — a function that returns the string when called. Where a Server Component hands text to a client component, it passes the string it called for (label={m.x.y()}), never the function.

Splitting a layout across two files

A paramsSchema can only be exported from a Server Component file, while a layout that uses hooks or providers is a client component. This site's [locale] layout splits the two across files: layout.tsx is the Server Component that holds the schema, and _parts/locale-shell.tsx is the client shell with the providers, the header and the hooks.

// src/routes/[locale]/layout.tsx
import type { ReactNode } from 'react';

import { locales } from '../../i18n';
import { LocaleShell } from './_parts/locale-shell';

export const { paramsSchema } = locales;

export default function LocaleLayout({
  params,
  children,
}: {
  params: { locale: string };
  children: ReactNode;
}) {
  return <LocaleShell locale={params.locale}>{children}</LocaleShell>;
}
// src/routes/[locale]/_parts/locale-shell.tsx
'use client';

import { usePathname } from '@k8ordo/router';
import { UIProvider } from '@k8ordo/ui';
import { dictionaries } from '@k8ordo/ui/i18n';
import type { ReactNode } from 'react';

import { locales } from '../../../i18n';

export function LocaleShell({
  locale: param,
  children,
}: {
  locale: string;
  children: ReactNode;
}) {
  const pathname = usePathname();
  const locale = locales.is(param)
    ? param
    : (locales.delocalize(pathname).locale ?? locales.default);
  return <UIProvider messages={dictionaries[locale]}>{children}</UIProvider>;
}

Both files are cut down to the part this section is about; the real shell also renders the header, the sidebar and the footer.

The layout's params.locale is typed as a string: around a page its schema has already accepted it, but around not-found.tsx nothing is validated and it can be anything — so the shell checks it with locales.is() and otherwise reads the locale from the URL. The children the server rendered cross the boundary as JSX.

Components that need a browser

A client component that reads something only a browser has — localStorage, the visitor's time zone, navigator — says so with React's use(browser()) (browser from react-dom, added in React 19.3), under a <Suspense>.

// src/routes/_parts/editor.tsx
'use client';

import { Suspense, use } from 'react';
import { browser } from 'react-dom';

function SavedDraft() {
  use(browser('the draft is stored in localStorage'));
  return <textarea defaultValue={localStorage.getItem('draft') ?? ''} />;
}

export function Editor() {
  return (
    <Suspense fallback={<p>loading the draft…</p>}>
      <SavedDraft />
    </Suspense>
  );
}

The server render — a build into files as much as a request — leaves the fallback in the HTML, and the browser renders the component after hydration. That is not a failure: the build does not stop for it and the handler logs nothing. This is what a typeof window check or a "mounted" flag used to do; neither is needed.

The <Suspense> is what says where the fallback goes, and it is not optional: with no Suspense boundary above the component, the server render has nowhere to leave one, and fails.

Server-only modules

A module that imports server-only may never reach the client.

The build fails when one does, and names the chain of imports that got it there — including through a client component's graph, which is assembled while rendering rather than crawled from an entry.

'server-only' cannot be imported in client build ('ssr' environment):
 imported by src/routes/_data/catalog.server.ts
  imported by src/routes/_parts/counter.tsx
   imported by virtual:vite-rsc/client-references

Secrets and database clients behind that import cannot cross, however many modules sit in between. server-only is the package React's ecosystem uses for this; the build resolves the specifier itself, and installing it is what lets TypeScript resolve it.

Name such a file *.server.ts. The guarantee comes from the import; the name is so a reader sees it in the directory tree and at every import site without opening the file. A third-party module that does not mark itself comes under the same check once wrapped in one.

Where the browser is

Under the framework the browser holds no route table: the tree comes from the server, and the browser has only navigation. useRoute() and useParams() therefore have no match to read, and throw. A page receives params as a prop and passes what a client component needs down as props; the current location is usePathname(), and whether a section is showing is useMatch().

// src/routes/_parts/where.tsx
'use client';

import { useMatch, usePathname } from '@k8ordo/router';

export function Where() {
  const pathname = usePathname();
  const inProducts = useMatch('/products/*') !== null;
  return <p data-section={inProducts ? 'products' : 'other'}>{pathname}</p>;
}

How the router behaves under the framework is covered here. Under the framework

The search is @k8ordo/state's

A page never sees the search. The pathname is the framework's, and everything after the ? is @k8ordo/state's: useAppState reads the search in the browser, so a server render shows the url slot's defaults and the live URL takes over on hydration. Changing only the search does not change the page — nothing remounts, and the scroll position stays where it was.

When the application depends on @k8ordo/state, the generated register.gen.ts writes its Register too, so state definitions are typed against the same route table. @k8ordo/state

The request a page receives in this mode carries no search either.