Next.js
ABBA UI is built for the App Router. The library's central design decision is where the "use client" directive lives — and it is never at the package root.
Setup
Import the stylesheet in your root layout. The layout itself stays a Server Component.
// app/layout.tsx — a Server Component
import "@abbainitiative/ui/styles.css";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}Do not add transpilePackages
A common instinct with a component library is to add it to transpilePackages. Do not. This package publishes compiled ESM with the directives already emitted in the right places; recompiling it is slower and can shift the boundary Next.js sees.
// next.config.ts
// transpilePackages is NOT needed. The package ships compiled ESM with
// "use client" already in place. Adding it makes Next.js recompile the
// source, which is slower and can move the client boundary.
export default {};This documentation site is itself the proof: it consumes the built package with no transpilePackages entry, so a regression in the published artefact breaks this site's build.
Why the directive placement matters
A library that puts "use client" at the top of its barrel file makes every component a Client Component — including a Stack that only sets display: flex. Importing one button then drags the entire library across the boundary and into the client bundle.
Here, each component file carries its own directive. A page importing Heading and Stack stays entirely on the server; a page importing Button ships only the button.
Here is a real Server Component page. It fetches data, renders four ABBA components, and never declares a client boundary of its own.
// app/dashboard/page.tsx
// No "use client" here. Button and Input carry their own boundaries.
import { Button, Card, CardBody, Heading, Input, Stack } from "@abbainitiative/ui";
export default async function DashboardPage() {
const user = await getUser();
return (
<Card>
<CardBody>
<Stack gap={4}>
<Heading level={1}>Hello, {user.name}</Heading>
<Input aria-label="Search" placeholder="Search…" />
<Button>Refresh</Button>
</Stack>
</CardBody>
</Card>
);
}Server-renderable components
These render inside a Server Component with no boundary of your own. They have no state and no event handlers.
BoxStackInlineContainerGridSeparatorVisuallyHiddenTextHeadingLabelCodeLinkButtonGroupFormMessageCardBadgeSpinner
Components with their own client boundary
These carry "use client" internally. You can still render them from a Server Component — you simply cannot pass them a function.
ButtonIconButtonInputTextareaFormFieldAlertDialogDropdownMenuTabsToast
The one rule to remember
A Server Component may render a Client Component, but it may not pass a function to one. Functions cannot be serialised across the boundary. So <Button>Save</Button> is fine from the server, while <Button onClick={…}> is not.
// This fails — a Server Component cannot pass a function to a
// Client Component.
export default function Page() {
return <Button onClick={() => save()}>Save</Button>;
}
// This works — move the handler into a client island.
"use client";
export function SaveButton() {
return <Button onClick={() => save()}>Save</Button>;
}In practice this means your interactive islands are small: a form, a menu bar, a settings panel. Everything around them — the page shell, headings, cards, badges, static alerts — stays on the server.
Toasts
ToastProvider uses context, so it needs a client boundary. Wrap it in your own client module and mount that from the server layout — the layout stays a Server Component, and only the provider crosses over.
// app/providers.tsx
"use client";
import { ToastProvider } from "@abbainitiative/ui";
export function Providers({ children }: { children: React.ReactNode }) {
return <ToastProvider>{children}</ToastProvider>;
}
// app/layout.tsx — still a Server Component
import { Providers } from "./providers";
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}Theming without a flash
Dark mode is driven by a data-theme attribute, so it can be set before React hydrates. See Dark mode for the blocking script that avoids a flash of the wrong theme.
Streaming and Suspense
Nothing in the library reads from the request, uses useLayoutEffect at module scope, or otherwise interferes with streaming. Components inside a <Suspense> boundary stream normally, and Spinner is a reasonable fallback — it announces itself as role="status", so the wait is not silent for screen reader users.
Turbopack
Supported with no configuration. The package is plain ESM with a static exports map, which is what Turbopack resolves best.