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 at build time: each page is rendered once for index.html and once more for index.rsc, and the results are written to files. Nothing runs again when a visitor arrives.
// 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' does not exist in this mode: the build and vite dev both refuse a module that declares it. Get Started
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), and the build stops.
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-referencesSecrets 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
A GET form works before JavaScript loads, so a search or filter form built with @k8ordo/form suits a static site. @k8ordo/form