Server and browser
React’s directives say which code runs on the server and which in the browser. You will also see what a Client Component can receive, and how to keep a server-only module off the client.
On this page
Server Components
src/lib/catalog.server.tsimport 'server-only';
export type Product = { id: number; name: string };
const products: readonly Product[] = [
{ id: 1, name: 'Lamp' },
{ id: 2, name: 'Desk' },
];
export const listProducts = async (): Promise<readonly Product[]> =>
products;src/routes/products/page.tsximport { href } from '@k8ordo/framework';
import { listProducts } from '../../lib/catalog.server';
export default async function ProductsPage() {
A Server Component can be `async`
const products = await listProducts();
return (
<ul>
{products.map((product) => (
<li key={product.id}>
<a href={href('/products/:id', { id: product.id })}>
{product.name}
</a>
</li>
))}
</ul>
);
}A file with no directive is a Server Component. It reads data directly. Only what it rendered is sent to the browser, never its code.
When a Server Component runs depends on the mode. In static mode it runs at build time. Each page is rendered twice, once for its HTML and once for index.rsc, and the results become files. In server mode it runs on every request and reads the data as it is at that moment. With vite dev it runs on every request in either mode.
'use client'
src/components/counter.tsx'use client';
import { useState } from 'react';
export function Counter() {
const [count, setCount] = useState(0);
return (
<button
onClick={() => {
setCount(count + 1);
}}
type="button"
>
{count}
</button>
);
}src/routes/page.tsximport { Counter } from '../components/counter';
export default function HomePage() {
return (
<>
<h1>Home</h1>
<Counter />
</>
);
}A component that runs in the browser starts its file with 'use client'. A Server Component imports it like any other module. Only that component, and what it imports, is sent to the browser.
A Client Component is also rendered to HTML on the server first, then hydrated in the browser. For a value the server cannot read, such as localStorage, see “Browser-only values” below.
'use server' declares a server function the browser can call. It is different from server-only, which a module imports so it is never sent to the browser. It works in server mode only; in static mode the build and vite dev refuse it. See Server Actions.
Props you can pass
Props from a Server Component to a Client Component are serialized, so only these values can be passed:
- Primitives: strings, numbers and booleans, plus
nullandDate - Collections: plain objects and arrays,
MapandSet Promise: the Client Component can wait on it withuse()- JSX: including
childrena Server Component rendered
Functions and class instances cannot be passed. Passing a function fails the page’s render with React’s error Functions cannot be passed directly to Client Components. In static mode the build stops. A Server Action is the exception: in server mode a 'use server' function is passed as a reference.
Splitting a layout
src/routes/[locale]/layout.tsximport type { ReactNode } from 'react';
import { LocaleShell } from '../../components/locale-shell';
import { locales } from '../../i18n';
export const { paramsSchema } = locales;
The schema lives here
export default function LocaleLayout({
children,
}: {
children: ReactNode;
}) {
return <LocaleShell>{children}</LocaleShell>;
}src/components/locale-shell.tsx'use client';
import { UIProvider } from '@k8ordo/ui';
import type { ReactNode } from 'react';
export function LocaleShell({ children }: { children: ReactNode }) {
return <UIProvider>{children}</UIProvider>;
}paramsSchema can only be exported from a file without 'use client', while a layout that uses hooks or providers is a Client Component. When you need both, split the layout into a layout.tsx that holds the schema and a Client Component it renders.
The children rendered on the server are passed to the Client Component as JSX. This site’s [locale] layout is written this way.
Browser-only values
src/components/editor.tsx'use client';
import { Suspense, use } from 'react';
import { browser } from 'react-dom';
function SavedDraft() {
use(browser('the draft is in localStorage'));
`SavedDraft` renders only in the browser
const draft = localStorage.getItem('draft') ?? '';
return <textarea defaultValue={draft} />;
}
export function Editor() {
return (
<Suspense fallback={<p>Loading the draft…</p>}>
What the server render shows
<SavedDraft />
</Suspense>
);
}A Client Component that reads something only a browser has, such as localStorage or the visitor’s time zone, passes browser() from react-dom to use(). No typeof window check or “mounted” flag is needed.
The server render leaves the fallback in the HTML, and the browser renders the component after hydration. That is not a failure: the build does not stop, and nothing is logged.
Pitfall
Without a <Suspense> above it, the server render fails with The server render could not complete because client rendering was requested outside a Suspense boundary.
server-only
npm install server-onlyvite build'server-only' cannot be imported in client build ('ssr' environment):
imported by src/lib/catalog.server.ts
imported by src/components/counter.tsx
imported by virtual:vite-rsc/client-referencesserver-only goes in the application’s dependencies. The build passes without installing it, but TypeScript’s type check needs it. A module that holds secrets or a database client imports server-only at the top. If the module ends up in the client bundle, the build stops with the error above, which lists the whole chain of imports that led to it.
However many modules sit in between, the build stops with the same error. Naming such a file *.server.ts tells a reader it is server-only without opening it.
The URL in the browser
src/components/where.tsx'use client';
import { useMatch, usePathname } from '@k8ordo/framework';
export function Where() {
const pathname = usePathname();
const inProducts = useMatch('/products/*') !== null;
return <p data-products={inProducts}>{pathname}</p>;
}In this framework the browser holds no route table and receives the page’s component tree from the server. So @k8ordo/framework does not export useRoute() or useParams().
A page receives params as props and passes what a Client Component needs down as props. usePathname() returns the current URL. useMatch() tells whether it matches a pattern such as /products/*.
The query
The framework handles the URL up to its pathname. The query after the ? is read by @k8ordo/state. A page does not receive the query; useAppState reads it in the browser, in a Client Component.
The server render shows the defaults of the url slot, and the real URL takes over on hydration. Changing only the query does not change the page: nothing remounts, and the scroll position stays.
In server mode there is one exception: a page that exports search receives the validated query as search. See Parameters. In static mode, a page that exports search stops the build with an error starting static build cannot hand a page the search, and vite dev refuses it too. For search and filtering, a GET form, which works before JavaScript loads, is the right fit. See Search forms.