Build your first admin screen
Add a protected admin route, sidebar item, and first content page to a Tulip app.
This page assumes you are using a generated Tulip app. The basic template already contains the root providers, auth setup, and admin layout, so the first useful step is adding your own admin route.
1. Check the root layout
The root layout should wrap the app in Tulip providers:
import "./globals.css";
import "../server/router/register.js";
import { Providers } from "@tulip-systems/app/components/client";
import { generateRootLayoutMetadata, RootLayout } from "@tulip-systems/app/components/server";
import type { PropsWithChildren } from "react";
import { config } from "@/lib/config/base";
export const { metadata, viewport } = generateRootLayoutMetadata(config);
export default function Layout({ children }: PropsWithChildren) {
return (
<RootLayout>
<Providers>{children}</Providers>
</RootLayout>
);
}2. Check the admin layout
Your admin route should use the protected Tulip admin shell:
import { AdminContent } from "@tulip-systems/app/components/client";
import { AdminLayout } from "@tulip-systems/app/components/server";
import { AuthProvider } from "@tulip-systems/auth/client";
import { AuthGuard } from "@tulip-systems/auth/next/guards";
import type { PropsWithChildren } from "react";
import { AdminSidebar } from "@/lib/config/paths";
import { authClient } from "@/server/auth/client";
import { auth } from "@/server/auth/init";
export default async function Layout(props: PropsWithChildren) {
return (
<AuthGuard auth={auth}>
<AuthProvider authClient={authClient}>
<AdminLayout>
<AdminSidebar />
<AdminContent>{props.children}</AdminContent>
</AdminLayout>
</AuthProvider>
</AuthGuard>
);
}The generated app already includes this pattern. Keep it in src/app/admin/layout.tsx so every admin page shares the same protection and shell.
3. Add a page
Create src/app/admin/customers/page.tsx:
import { Card, CardContent, CardHeader, CardTitle } from "@tulip-systems/ui/card";
const customers = [
{ name: "Acme Studio", status: "Active" },
{ name: "Northwind", status: "Onboarding" },
{ name: "Bluebird Logistics", status: "Paused" },
];
export default function Page() {
return (
<div className="grid gap-5 p-content">
<Card>
<CardHeader>
<CardTitle>Customers</CardTitle>
</CardHeader>
<CardContent>
<div className="divide-y rounded-md border">
{customers.map((customer) => (
<div className="flex items-center justify-between p-4" key={customer.name}>
<span className="font-medium">{customer.name}</span>
<span className="text-muted-foreground text-sm">{customer.status}</span>
</div>
))}
</div>
</CardContent>
</Card>
</div>
);
}This is deliberately static. The goal is to confirm the shell, route, spacing, and UI imports before adding server data.
4. Add a sidebar item
Add the route to your admin sidebar, usually in src/lib/config/paths.tsx:
import { LayoutDashboardIcon, UsersIcon } from "lucide-react";
import {
Path,
PathGroup,
PathGroupItems,
PathLink,
Sidebar,
SidebarContent,
SidebarHeader,
SidebarRail,
} from "@tulip-systems/app/components/client";
export function AdminSidebar() {
return (
<Sidebar>
<SidebarHeader />
<SidebarContent>
<PathGroup>
<PathGroupItems>
<Path segment="(dashboard)">
<PathLink href="/admin">
<LayoutDashboardIcon />
Dashboard
</PathLink>
</Path>
<Path segment="customers">
<PathLink href="/admin/customers">
<UsersIcon />
Customers
</PathLink>
</Path>
</PathGroupItems>
</PathGroup>
</SidebarContent>
<SidebarRail />
</Sidebar>
);
}If your generated app already has account menu or footer content, keep it. Only add the new customers path.
5. Run the app
Start the dev server and open the new page:
pnpm devVisit /admin/customers after signing in.
What to add next
Once the static page works, replace the local array with real data and package-specific behavior:
- Use
Queryfor filters, sorting, pagination, and URL-backed state. - Use
Data Tableswhen the list needs column configuration and loading strategies. - Use
Commandswhen rows need actions such as archive, restore, delete, or status updates. - Use
Inlinewhen users should edit fields without opening a full form.