Web guide
How the admin panel is built: feature slices, the design system, data fetching, who gets in, and how to add a page step by step.
This guide covers how the admin panel in apps/web is built: where code goes, how it talks to the API, how it looks, how a visitor is let in, and how to add a page. Frontend rules from the project's apps/web/CLAUDE.md are folded in here, and each claim points to the file it comes from.
Next.js 16 differs from older versions. Before you use a Next.js API, read the matching guide in apps/web/node_modules/next/dist/docs/.
How the code is laid out
apps/web/src/
app/ routes (Next.js App Router)
features/ one folder per feature: its API calls, components, types
shared/ what several features use: ui primitives, layout, api client, styles
providers/ React context providers
content/docs/ these docs, as MDX
proxy.ts the gate for logged-out visitors
app/holds routes and nothing else that matters: a route file reads its parameters and renders a view. The dashboard is one route group (app/dashboard) behind a single layout that wraps it inProtectedRouteand the dashboard shell.features/<name>/is a slice. It owns itsapi/(service, query keys, queries, mutations, mappers), itscomponents/, and itstypes/. Features are independent of each other: when two need the same thing, it moves toshared/. The inbound webhooks feature, used as the worked example below, also has aschema/folder for the editor's helpers.shared/holds the primitives (components/ui), the layout (sidebar, top bar, navigation), common pieces such as the error boundary, theapiClient, thecn()helper, and the design tokens.
Names: files are kebab-case.tsx (older ones, such as LogoutButton, are PascalCase folders), components are named exports, and only Next.js pages and layouts use export default. Path alias @/ means src/.
The design system
The panel is dark only. There is no light mode, no theme switch and no dark: class. The whole palette is a set of tokens in src/shared/styles/globals.css, declared in a Tailwind v4 @theme block, since the project has no tailwind.config.ts.
/* Surface Colors (Layered Dark) */
--color-surface-base: #1e1f22;
--color-surface-raised: #2b2d31;
--color-surface-main: #313338;
--color-surface-hover: #35363c;
--color-surface-active: #404249;
--color-surface-elevated: #4e5058;
/* ... */
/* Text Hierarchy */
--color-text-primary: #f2f3f5;
--color-text-normal: #dbdee1;
--color-text-muted: #b5bac1;
--color-text-subtle: #949ba4;
--color-text-faint: #6d6f78;
/* ... */
--text-hero: 22px;
--text-hero--font-weight: 800;
--text-hero--line-height: 1.2;
Use the token as a utility class: bg-surface-raised, text-text-muted, border-border. The groups are:
| Group | Tokens |
|---|---|
| Brand | brand, brand-hover, brand-active, brand-light, brand-tint, and on-brand for text on a brand-filled surface |
| Surfaces | surface-base, surface-raised, surface-main, surface-hover, surface-active, surface-elevated |
| Text | text-primary, text-normal, text-muted, text-subtle, text-faint |
| State | success, error, warning, info, accent |
| Borders | border, border-subtle, border-hover, border-focus |
| Type scale | text-display, text-lead, text-hero, text-heading, text-subhead, text-body, text-overline, text-code |
Rules that keep it consistent:
- Do not write a hex colour in a component. Use a token. If one is missing, add it to
globals.css. - Build screens from the primitives in
src/shared/components/ui(button, input, select, dialog, data table, badge, card, tabs and the rest) before writing your own. - Icons come from
lucide-reactonly, and decorative ones carryaria-hidden="true". - Accessibility is part of the work: semantic HTML, labels, visible focus, keyboard use.
cn() and the type scale
cn() joins class names and resolves conflicts between them. It is clsx followed by tailwind-merge:
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
tailwind-merge does not know the project's type-scale tokens. It reads text-body and text-text-primary as two classes of the same kind and keeps only the last, so the type size is silently dropped:
cn('text-body text-text-primary') -> 'text-text-primary'
cn('text-hero text-text-muted') -> 'text-text-muted'
When a heading or label loses its size, that is why. Join the classes with clsx instead, as the docs components do, or put the type-scale class on a parent element.
Talking to the API
Every request goes through one apiClient in src/shared/lib/api-client.ts. Never call fetch for an API route yourself and never create a second client.
const response = await fetch(`${this.baseURL}${endpoint}`, {
...fetchOptions,
headers,
credentials: 'include',
});
- The session is a cookie. The API sets
admin_sessionas an httpOnly cookie on its own origin, and JavaScript cannot read it.credentials: 'include'makes the browser send it to the API from the panel's origin. Without it every authenticated call is a 401. - A 401 is final. Admin sessions last 24 hours and cannot be refreshed, so on a 401 the client clears the local session and the app returns to the login page.
- Errors have one shape. A failed call throws an
ApiErrorwithmessage,codeandstatus, built from Nest's error body. When the API sends several messages (validation), they are joined with;. - Bodies arrive as the API sends them. The API returns resources without an envelope; the client wraps the result as
{ data, status }, so every caller readsresponse.data. A 204 givesdata: null. - Text responses.
getTextasks for a document instead of JSON, for endpoints such as the generated webhook guide.
NEXT_PUBLIC_API_URL must include the /api prefix. Paths you pass to the client start after it, such as /admin/projects.
Data fetching with TanStack Query
Server state lives in TanStack Query, client-only state in Zustand. The provider (src/providers/QueryProvider.tsx) sets the defaults: data is fresh for 60 seconds, kept for 5 minutes, queries and mutations retry once, and a query does not refetch when the window regains focus.
Each feature's api/ folder follows the same five-file pattern:
| File | What it holds |
|---|---|
service.ts | One function per endpoint. Plain functions that call apiClient and return its response. No React. |
keys.ts | The query key factory. Every key starts with the feature name, so one invalidateQueries can clear a whole feature. |
queries.ts | useQuery hooks that call a service function and map the result. |
mutations.ts | useMutation hooks. After a success they update or invalidate the keys the change affects. |
mappers.ts | Pure functions that turn API DTOs into the view models components render, and form values into request payloads. |
Components use these hooks instead of calling a service function themselves, and they render the view model, not the raw DTO. Mapping in one place means a component cannot show a field it should not, for example a secret that arrived by accident.
Who gets in
Three layers decide whether a visitor sees a page.
const publicRoutes = ['/', '/login', '/callback', '/api/health'];
const isPublicRoute =
publicRoutes.some((route) => pathname === route || pathname.startsWith('/api/')) ||
pathname === '/docs' ||
pathname.startsWith('/docs/') ||
pathname === '/sitemap.xml';
proxy.tsruns on the server before any HTML is sent. The public routes are the landing page/,/login,/callback, the docs under/docs,/sitemap.xmland anything under/api/. The matcher also lets image files frompublic/through, because the Next.js image optimizer fetches them without cookies. Every other route needs theauth-tokencookie, and without it the visitor is sent to/login?redirect=<path>. A visitor who has the cookie and opens/loginis sent on to the dashboard.ProtectedRoutewraps the dashboard layout. The server cannot readlocalStorage, so for the first paint, while the Zustand store rehydrates, it shows a loading state, and then it redirects to the login page if the store says nobody is signed in.- The access rules decide what a signed-in admin sees.
GET /api/auth/admin/mereturns the member's effective level per resource, and the panel keeps it in the auth store.src/shared/lib/route-access.tslists who may open each dashboard page, and the page guard refuses a page that is not listed.useCan(resource, level)and<Can>hide the actions a member's level does not reach, and every query hook is disabled when the member cannot read its resource. The panel only adapts to these rules: the API refuses what a member may not do, and a refused call refreshes the member's access. - The Access screen (
/dashboard/access, root only) is where a root admin grants levels. The Roles tab edits one role at a time: a level per resource, saved as one request that replaces the role's grants. The Members tab shows every member in a searchable, paginated list on the left; selecting one opens their permissions on the right: the level they hold on every area, where it comes from, and an override control per area (Inherit, none, read, write, manage), with members who already have an override marked in the list. The same editor backs each member's Access page (/dashboard/members/[discordId]/access). A save that lowers a level asks first, and a grant that cannot be reached in the panel because a prerequisite is missing (for example Channels without Servers) is flagged. SessionProviderchecks the session againstGET /auth/admin/mewhile the store believes someone is signed in. If the API refuses, it clears the store, shows a toast and returns to/login.
The Zustand store (features/auth/stores/auth.ts, persisted under auth-storage) holds the admin's profile and when the session ends. The auth-token cookie is not the session. It is a flag set only after /auth/admin/me has answered 200, so the proxy can decide without a round trip. The real session is the httpOnly admin_session.
Sign-in is a redirect: the login button goes to the API's Discord OAuth, the API sets the cookie and redirects to /callback, and AuthCallbackHandler calls /auth/admin/me to confirm, fills the store and sends the admin where they were going.
Heavy code is loaded on demand
A large dependency must not weigh down every page. The inbound webhook schema editor is CodeMirror, so it is imported with next/dynamic and without server rendering, and a plain box holds its place while it loads:
const SchemaEditor = dynamic(
() =>
import('@/features/inbound-webhooks/components/schema-editor').then(
(module) => module.SchemaEditor
),
{
ssr: false,
loading: () => <div className="h-96 rounded-md border border-border bg-surface-base" />,
}
);
Do the same for any editor, chart or large library that only one screen uses.
Add a page, step by step
The worked example is the inbound webhooks list of a project, at /dashboard/projects/<id>/inbound-webhooks. Every excerpt is copied from the repository, and a docs test fails if one drifts from its file.
Step 1: Add the route
A route is a folder in src/app. The page.tsx is a server component that reads its parameters and hands them to a view:
import { InboundWebhooksView } from './inbound-webhooks-view';
export default async function ProjectInboundWebhooksPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
return <InboundWebhooksView projectId={id} />;
}
The view is a client component ('use client') in the same folder. It owns the screen: the heading, the states, the layout.
export function InboundWebhooksView({ projectId }: { projectId: string }) {
const query = useInboundWebhooksQuery(projectId);
const newHref = `/dashboard/projects/${encodeURIComponent(projectId)}/inbound-webhooks/new`;
Step 2: Add the feature slice
Put the data code in src/features/<name>/api. Start with the service, one plain function per endpoint:
const BASE = '/admin/inbound-webhooks';
// ...
export function fetchInboundWebhooks(projectId: string): Promise<ApiResponse<InboundWebhookDto[]>> {
return apiClient.get<InboundWebhookDto[]>(`${BASE}?projectId=${encodeURIComponent(projectId)}`);
}
Then the keys, which every query and invalidation will use:
export const inboundWebhookKeys = {
all: ['inbound-webhooks'] as const,
lists: (projectId: string) => [...inboundWebhookKeys.all, 'list', projectId] as const,
detail: (webhookId: string) => [...inboundWebhookKeys.all, 'detail', webhookId] as const,
docs: (webhookId: string) => [...inboundWebhookKeys.all, 'docs', webhookId] as const,
submissions: (webhookId: string, filters: SubmissionFilters) =>
[...inboundWebhookKeys.all, 'submissions', webhookId, filters] as const,
submission: (webhookId: string, submissionId: string) =>
[...inboundWebhookKeys.all, 'submission', webhookId, submissionId] as const,
roles: (webhookId: string) => [...inboundWebhookKeys.all, 'roles', webhookId] as const,
settings: () => [...inboundWebhookKeys.all, 'settings'] as const,
preview: (payload: PreviewSchemaPayload) =>
[...inboundWebhookKeys.all, 'preview', payload] as const,
};
Step 3: Map the data, then query it
The mapper is where the DTO becomes what the screen renders. It copies display fields only:
export interface InboundWebhookView {
id: string;
projectId: string;
name: string;
slug: string;
isActive: boolean;
signatureLabel: 'Signed' | 'Unsigned';
submissionCount: number;
lastSubmissionLabel: string;
}
// ...
/** Copies only display fields, so nothing sensitive reaches the list by accident. */
export function mapInboundWebhook(dto: InboundWebhookDto): InboundWebhookView {
return {
id: dto.id,
projectId: dto.projectId,
name: dto.name,
slug: dto.slug,
isActive: dto.isActive,
signatureLabel: dto.requireSignature ? 'Signed' : 'Unsigned',
submissionCount: dto.submissionCount,
lastSubmissionLabel: formatDate(dto.lastSubmissionAt) ?? 'Never',
};
}
The query hook calls the service and maps the answer:
export function useInboundWebhooksQuery(projectId: string) {
return useQuery({
queryKey: inboundWebhookKeys.lists(projectId),
queryFn: async () => {
const response = await fetchInboundWebhooks(projectId);
return response.data.map(mapInboundWebhook);
},
});
}
A mutation updates what the change touched. Creating a webhook only needs the project's list refreshed:
export function useCreateInboundWebhookMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: CreateInboundWebhookPayload) => createInboundWebhook(payload),
onSuccess: (_response, payload) => {
void queryClient.invalidateQueries({
queryKey: inboundWebhookKeys.lists(payload.projectId),
});
},
});
}
Step 4: Show it with the shared primitives
Build the view from shared/components/ui and your feature's components, using tokens for every colour. Handle the four states a list has: loading, empty, failed with a retry, and loaded.
Step 5: Link it in the navigation
A project's pages are listed in src/shared/components/layout/nav-items.ts. Adding a line puts the page in the sidebar under the selected project:
{ name: 'Keys & settings', segment: '', icon: KeyRound },
{ name: 'Server access', segment: 'access', icon: Network },
{ name: 'Webhooks', segment: 'webhooks', icon: Webhook },
{ name: 'Inbound webhooks', segment: 'inbound-webhooks', icon: Inbox },
],
Step 6: Write the tests
Tests live only under apps/web/tests, mirroring src. A mapper is a pure function, so its test is plain:
describe('mapInboundWebhook', () => {
it('labels what the list shows', () => {
const view = mapInboundWebhook(dto);
expect(view).toMatchObject({
id: 'wh_1',
name: 'Recruitment 2026',
slug: 'recruitment-2026',
isActive: true,
signatureLabel: 'Signed',
submissionCount: 1234,
});
expect(view.lastSubmissionLabel).not.toBe('Never');
});
A page test renders the view with a fresh QueryClient and answers the API with MSW, then checks what the visitor sees:
function wrapper({ children }: { children: ReactNode }) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}
// ...
it('lists the webhooks with their readers and counts', async () => {
server.use(
http.get(`${API_URL}/admin/inbound-webhooks`, ({ request }) => {
expect(new URL(request.url).searchParams.get('projectId')).toBe('proj_1');
return HttpResponse.json([
webhook('wh_1', 'Recruitment'),
webhook('wh_2', 'Workshop', { requireSignature: false, isActive: false }),
]);
}),
http.get(`${API_URL}/admin/inbound-webhooks/wh_1/roles`, () =>
HttpResponse.json([{ roleId: EXECUTIVE, roleName: 'MC Executive', serverId: 'srv-main' }])
),
http.get(`${API_URL}/admin/inbound-webhooks/wh_2/roles`, () => HttpResponse.json([]))
);
render(<InboundWebhooksView projectId="proj_1" />, { wrapper });
// ...
expect(within(row).getByText('Signed')).toBeInTheDocument();
expect(within(row).getByText('1,204')).toBeInTheDocument();
The server comes from tests/setup.ts. Write the test first, see it fail, then write the code that makes it pass. See Testing.
Step 7: Run the checks
From apps/web:
pnpm run typecheck
pnpm run lint
pnpm run format:check
pnpm run test
pnpm run build
The docs live here too
These docs are MDX in src/content/docs, served by the same app at /docs. Writing these docs explains how to add a page, and Contributing says when a change needs one.
Source: apps/web/CLAUDE.md, apps/web/src/app/dashboard/layout.tsx, apps/web/src/app/dashboard/projects/[id]/inbound-webhooks/page.tsx, apps/web/src/features/inbound-webhooks/api, apps/web/src/features/auth, apps/web/src/providers/QueryProvider.tsx, apps/web/src/shared/lib/api-client.ts, apps/web/src/shared/lib/access.ts, apps/web/src/shared/lib/route-access.ts, apps/web/src/shared/lib/use-access.ts, apps/web/src/shared/lib/utils.ts, apps/web/src/shared/lib/cookies.ts, apps/web/src/features/access, apps/web/src/app/dashboard/access, apps/web/src/shared/styles/globals.css, apps/web/src/shared/components/layout/nav-items.ts, apps/web/src/proxy.ts, apps/web/tests/app/dashboard/inbound-webhooks.test.tsx, apps/web/tests/setup.ts.