Getting started

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.