Creating a new page
How to build a new page in @camunda/orchestration-cluster-webapp.
Read Before starting first; loader patterns
live in Data loading.
Overview
Each pod owns its area of the source tree:
src/operate/— Operate podsrc/tasklist/— Tasklist podsrc/admin/— Admin pod
Pod areas are autonomous: pods decide their own internal folder structure, naming conventions, and patterns. There is no prescribed layout inside a pod.
src/shared/ holds cross-cutting infrastructure (http, auth, config,
errors, theme, i18n, tracking) that all pods can import. Changes to
shared code require cross-pod coordination.
src/routes/ is shared routing infrastructure. Route files are thin
wrappers that import pod-owned components and wire them into the router.
Default to a new route
If a user can navigate to it, it gets its own URL. Routes are cheap, the payoff is large: deep-linkable views, working back/forward, shareable filter state, decoupled components, refresh that preserves intent.
Skip a route only for:
- Transient overlays such as toasts, snackbars, command palette, and hover cards.
- Modals that should not be linkable, like confirmations and inline edit dialogs.
- In-page tabs on a page already gated by the same data. Encode the
active tab as a search param (
/_auth/processes?tab=completed).
Anything else, justify it in the PR description.
Step 1: Build the feature in your pod's area
Create your page component and any supporting code inside your pod's
directory (src/operate/, src/tasklist/, or src/admin/). How you
organise that code internally is your pod's decision.
For cross-cutting concerns, reuse what's already in src/shared/:
- HTTP requests and query options →
#/shared/http/ - Authentication state →
#/shared/auth/ - App configuration →
#/shared/config/ - Error types →
#/shared/errors/ - i18n →
#/shared/i18n/ - Theme →
#/shared/theme/
Example page component (Operate pod, structure is illustrative):
// src/operate/pages/DashboardPage.tsx
import {DashboardHeader} from '#/operate/components/DashboardHeader';
import {Metrics} from '#/operate/components/Metrics';
import type {Dashboard} from '#/shared/http/queries';
import styles from './DashboardPage.module.scss';
export function DashboardPage({data}: {data: Dashboard}) {
return (
<main className={styles.page}>
<DashboardHeader title={data.title} />
<Metrics metrics={data.metrics} />
</main>
);
}
Pages are glue: receive data as props, orchestrate components. No
fetch, no business logic. Prefer prop-passing — the route reads the
loader query and passes data down. Reach for useSuspenseQuery inside
the page only when prop drilling through a deep tree hurts more than it
helps. See Data loading for the loader/query
patterns.
Step 2: Plug into the router
Add a route file under src/routes/_auth/{pod}/. File path = URL path.
// src/routes/_auth/operate/dashboard.tsx
import {createFileRoute} from '@tanstack/react-router';
import {useSuspenseQuery} from '@tanstack/react-query';
import {dashboardQueryOptions} from '#/shared/http/queries';
import {DashboardPage} from '#/operate/pages/DashboardPage';
export const Route = createFileRoute('/_auth/operate/dashboard')({
loader: ({context: {queryClient}}) => {
queryClient.ensureQueryData(dashboardQueryOptions);
},
component: Dashboard,
});
function Dashboard() {
const {data} = useSuspenseQuery(dashboardQueryOptions);
return <DashboardPage data={data} />;
}
queryClient.ensureQueryData is the preferred loader. Skip
pendingComponent / errorComponent only when a router-level default
applies. Full tier breakdown in Data loading.
Use the URL as state
The URL holds the page's state. Components read from it, not from each other.
- Route params: entity identity (
/_auth/operate/processes/$processKey). - Search params: view state including filters, sort, cursor, selection, active tab, and modal-open flag.
- Local React state: ephemeral UI only, such as open menu, input draft, hover, and focus.
Payoff: deep-linkable, refresh preserves intent, back/forward work, siblings stay decoupled.
function StateFilter() {
const {state} = useSearch({from: '/some-router'});
const navigate = useNavigate();
return (
<select
value={state}
onChange={(e) =>
navigate({
search: (prev) => ({...prev, state: e.target.value || undefined}),
})
}
>
<option value="">All</option>
<option value="active">Active</option>
<option value="completed">Completed</option>
</select>
);
}
Lifting state to a common parent? Route it through the URL first.
Validate URL inputs with Zod
URLs are user input. Pin every search-param and path-param shape with Zod.
// src/routes/_auth/operate/processes.tsx
import {createFileRoute} from '@tanstack/react-router';
import {z} from 'zod';
const searchSchema = z.object({
state: z.enum(['active', 'completed', 'canceled']).optional(),
cursor: z.string().optional(),
limit: z.number().int().min(1).max(100).default(50),
});
export const Route = createFileRoute('/_auth/operate/processes')({
validateSearch: searchSchema,
// loader, component, ...
});
validateSearch for search params, parseParams for path params.
Reuse schemas from @camunda/camunda-api-zod-schemas when the URL
slice maps to an API contract
(Camunda API Zod schemas). Otherwise
co-locate the schema in the route file.
useSearch() and useParams() return the validated, typed output.
Checklist
Before opening the PR:
- Feature code lives in the pod's area (
src/{operate,tasklist,admin}/). - Shared cross-cutting concerns reused from
src/shared/where applicable. - Route file in
src/routes/_auth/{pod}/{path}.tsx; URL matches file path; auth-gated routes under_auth/. - Loader uses
queryClient.ensureQueryData;pendingComponent+errorComponentwired or a router-level default applies. - Search params validated via
validateSearch+ Zod; path params viaparseParams. - View state lives in the URL.
- Dev server boots the new route (
npm run dev:oc).