Building the UI
How the pieces fit together — the shell, the gates, and the screens you assemble from them.
The SDK ships around forty React components. Listing them alphabetically is what a reference does, and it is not much help when the question is "what do I actually put on the page?"
So this section is arranged the way you build: the shell that wraps everything, the gates that decide whether a screen renders at all, then the screens themselves.
The three layers
Every partner application ends up with the same shape, whether or not it was planned:
| Layer | What it does | Components |
|---|---|---|
| Shell | Chrome that persists across every route — the rail, the workspace switcher, the account row | AppShell |
| Gate | Decides whether a screen may render, and what to show instead | ConnectAccessGate, ConnectFeatureGate, ConnectSubscriptionGate, ConnectWorkspaceTypeGate |
| Screen | The page itself | ConnectPageShell + your own components + SDK widgets |
The layers only ever look downward. A gate may live inside the shell; the shell never lives inside a gate. Getting that backwards is the single most common integration mistake, and it shows up as the sidebar disappearing whenever a subscription lapses — the user loses their navigation at exactly the moment they need it to go and fix their billing.
Never gate the chrome
AppShell renders its chrome unconditionally and gates only the page
body. If you wrap <AppShell> in a gate instead of the other way
round, a user whose plan expired sees a blank page with no way out.
The whole thing, once
This is a complete application layout. Everything else in this section is a detail of one of these lines.
"use client";
import { ConnectProvider, AppShell } from "@cosmoops/connect-sdk/client";
import Link from "next/link";
import { usePathname } from "next/navigation";
const NAV = [
{ label: "Dashboard", href: "/", icon: <LayoutDashboard className="size-5" /> },
{ label: "Campaigns", href: "/campaigns", icon: <Send className="size-5" /> },
];
// The routes you go to in order to change how the job works, not to do it.
const SETTINGS_NAV = [
{ label: "Settings", href: "/settings", icon: <Settings className="size-5" /> },
];
// `connectProviderProps()` is server-only, so the origin arrives as a prop.
export function Shell({
children,
connect,
}: {
children: React.ReactNode;
connect: { appUrl: string };
}) {
const pathname = usePathname();
return (
<ConnectProvider {...connect}>
<AppShell
name="Pulse"
nav={NAV}
secondaryNav={SETTINGS_NAV}
activePath={pathname}
renderLink={({ href, children, ...props }) => (
<Link href={href} {...props}>{children}</Link>
)}
>
{children}
</AppShell>
</ConnectProvider>
);
}import { connectProviderProps } from "@cosmoops/connect-sdk/next";
import { Shell } from "./providers";
import "@cosmoops/connect-sdk/theme.css";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<Shell connect={connectProviderProps()}>{children}</Shell>
</body>
</html>
);
}usePathname is a client hook, so the shell lives in its own "use client"
file and the root layout stays a Server Component — everything inside
{children} still renders on the server.
Call connectProviderProps() in the layout, not the shell
It reads CONNECT_APP_URL, which is server-side only, so it ships from
@cosmoops/connect-sdk/next — an entry a Client Component cannot import. Try it
inside "use client" and the build fails with "no matching export ...
this-entry-is-server-only", which is the error doing its job rather than a bug.
Call it in the Server Component and pass the result down, as above. Threading
one plain object is also cheaper than it looks: it is serializable, so it
crosses the boundary with no client bundle cost.
Five things are worth pulling out of that.
ConnectProvider is the only thing that talks to Connect. Every hook and
every component reads from it. It calls your origin — the bridge route from
Getting started — never Connect directly, so no
credential is ever in the browser.
Routing is a prop, not a dependency. renderLink and activePath are how
the SDK stays framework-agnostic. Pass next/link and usePathname() in Next,
your router's equivalents elsewhere.
The access gate is on by default. AppShell runs ConnectAccessGate
around its children — suspended workspace, no plan, onboarding pending,
payment overdue. You do not wire it up; you opt out per route if you need to.
The workspace switcher is on by default too, and hides itself for accounts with one workspace. See Workspaces.
appUrl is the one prop a non-production setup must supply, which is what
the connect prop above carries. The rail's account menu and app launcher
navigate to {appUrl}/..., and the SDK cannot read your server's
CONNECT_APP_URL from the browser — so a shell without it keeps working and
sends every one of those links to production Connect. See
Environments.
Where to go next
- Shell and navigation — the two navs, collapsing, the account row, exempting routes from the gate
- Gates — the four gates, and which question each one answers
- Screens — assembling a page from
ConnectPageShell,ConnectSectionand the widgets - Workspaces — the switcher, switching semantics, and rendering a team
Theme
Every component paints with semantic tokens only — accent, muted,
surface, separator, danger. None of them contain a hardcoded colour, so
they inherit whatever theme your application defines.
import "@cosmoops/connect-sdk/theme.css";That stylesheet is a complete, sensible default. Override the tokens in your own CSS and every SDK component follows — that is the whole contract, and it is why a component that hardcoded a colour would be a bug rather than a preference.