Server Components and SSR
Camaleo renders on the server with the skin already applied. No theme script, no flash, and most layout pieces stay Server Components.
// you'll learn
- Which components are Server Components and which ones draw a
"use client"boundary - How to set the skin, mode and brand on
<html>from a root layout - How to keep a visitor's light or dark choice without a flash
- How built-in texts reach Server Components through
Message
Server and client components
Each component lives in its own file in packages/react/src, and the build keeps those files separate, so a "use client" line never leaks into a file that doesn't need it. You can import everything from @camaleo/react in a Server Component: React turns each client component into a boundary on its own.
These files have no "use client". Their components render on the server only and ship no JavaScript:
| File | Components |
|---|---|
alert.tsx | Alert, AlertTitle, AlertDescription |
app-shell.tsx | AppShell, AppShellHeader, AppShellBody, AppShellContent, AppShellFooter, SkipLink |
data-display.tsx | Stat and its parts, DataList and its parts, Timeline and its parts, EmptyState and its parts, ProgressCircle |
display.tsx | Badge, Kbd, Card and its parts, Table and its parts, Skeleton |
layout.tsx | Box, Stack, Grid, Container, AspectRatio |
spinner.tsx | Spinner |
typography.tsx | Heading, Text |
visually-hidden.tsx | VisuallyHidden |
style-scope.ts | styleScopeProps() (a plain function) |
Everything else is a client component: every interactive part (built on Base UI or React Aria), plus StyleProvider, MessagesProvider and DirectionProvider, which hold React context. Client components still render to HTML on the server; they just hydrate in the browser.
Table stays a Server Component even though it scrolls: only its small scroll container (table-scroll.tsx) is a client component, and it only becomes a focusable region when the table overflows.
Set the skin on <html>
In the root layout, spread styleScopeProps() on <html>. It is a plain function, safe in a Server Component, that returns data-style, data-mode and the brand scale as inline custom properties. The skin is in the very first byte of HTML, so nothing waits for JavaScript.
import "@camaleo/styles/base.css";
import "@camaleo/styles/glass.css";
import { styleScopeProps } from "@camaleo/react/style-scope";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" {...styleScopeProps({ styleName: "glass", brand: "#3fa34d" })}>
<body>{children}</body>
</html>
);
}
Import base.css once and the CSS of every skin you render. Dialogs, menus and toasts are portaled into <body>, so they inherit the skin from <html> with no extra work.
Keep the visitor's mode without a flash
Camaleo's colors use light-dark() and data-mode only sets color-scheme. Without mode, the page follows the system with pure CSS: there is no script that runs before paint, so there is nothing to flash.
When visitors pick light or dark themselves, keep the choice in a cookie and read it in the layout. The server renders the right data-mode on the first response.
import { cookies } from "next/headers";
import { styleScopeProps } from "@camaleo/react/style-scope";
const MODES = ["light", "dark", "system"] as const;
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const saved = (await cookies()).get("cml-mode")?.value;
// Only trust known values: the cookie comes from the browser.
const mode = MODES.find((m) => m === saved) ?? "system";
return (
<html lang="en" {...styleScopeProps({ styleName: "glass", mode })}>
<body>{children}</body>
</html>
);
}
Write the cookie from a Server Function. Used as a form action, it returns the re-rendered page in the same round trip:
"use server";
import { cookies } from "next/headers";
export async function setMode(form: FormData) {
const mode = form.get("mode");
if (mode !== "light" && mode !== "dark" && mode !== "system") return;
(await cookies()).set("cml-mode", mode, { maxAge: 60 * 60 * 24 * 365 });
}
import { Button } from "@camaleo/react";
import { setMode } from "./actions";
export function ModeSwitch() {
return (
<form action={setMode}>
<Button type="submit" name="mode" value="dark" variant="outline">
Dark
</Button>
</form>
);
}
cookies() is async in this version of Next.js, and reading it makes the route dynamic. If the page must stay static, leave mode out and let the system decide.
Add islands with StyleProvider
StyleProvider is a client component, but it renders its <div data-style> on the server like any other. A section in another skin is in the HTML from the start:
import { Button, StyleProvider } from "@camaleo/react";
export default function Page() {
return (
<StyleProvider styleName="neubrutalism" mode="light">
<Button>Get started</Button>
</StyleProvider>
);
}
Popups opened inside the island keep its skin: DialogContent and the other portaled parts read the provider's scope from context and apply it again.
Set the language from the server
MessagesProvider takes a bundled set by name, which is a plain string and crosses the server–client boundary without trouble:
import { MessagesProvider } from "@camaleo/react";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="pt-BR">
<body>
<MessagesProvider locale="pt-BR">{children}</MessagesProvider>
</body>
</html>
);
}
Overrides made only of strings work here too. Some messages are functions (pagination.page, carousel.goToSlide…), and functions can't be passed from a Server Component, so override those from a client component. The Languages & RTL guide has every key.
The Message helper
Server Components can't read React context, so Spinner, SkipLink and ProgressCircle can't call useMessages(). Their default label is <Message pick="…" /> instead: a tiny client component that prints one message from the nearest MessagesProvider. The rest of each component stays on the server.
You can use it in your own Server Components for the same three keys:
import { Message } from "@camaleo/react";
// A visible "Loading…" in the language of the nearest MessagesProvider.
export default function Loading() {
return (
<p role="status">
<Message pick="spinner.label" />…
</p>
);
}
pick accepts "skipLink.label", "spinner.label" and "progressCircle.label". For any other text, use useMessages() in a client component.
Before you ship it
- View the page source:
<html>(or your provider's<div>) already hasdata-style - Every skin you render has its CSS imported in the root layout
- A mode read from a cookie is checked against the known values
- No layout got
"use client"just to use Camaleo MessagesProviderin a Server Component getslocaleor string-onlymessages