Files
probo/contrib/claude/app-arborescence.md

15 KiB

App arborescence (folder and file layout)

Conventions for organising pages, routes, and supporting files in Probo frontend apps (apps/console). The guiding principle is one arborescence: the route hierarchy is expressed once, through the pages/ folder tree, and everything related to a route lives next to it.

The codebase does not fully match these rules yet. Some route definitions still live in a separate src/routes/ folder. Treat this guide as the target for new work and refactors.

Topic Guide
@probo/ui, Tailwind, tailwind-variants, folders, skeletons, compound modules contrib/claude/ui.md
React component shape, props, file/export conventions contrib/claude/react-components.md
Relay queries, fragments, loaders, queryRef contrib/claude/relay.md

Single arborescence principle

The pages/ folder is the route tree. Every route segment maps to a folder under pages/, and route definitions live inside that folder as routes.ts. No other root-level folder should replicate the same hierarchy.

Do / don't: route file placement

// Bad — separate routes/ folder duplicates pages/ structure
src/
  routes/
    vendorRoutes.ts          # route definitions for vendors
    assetRoutes.ts           # route definitions for assets
  pages/
    organizations/
      vendors/
        VendorsPage.tsx
      assets/
        AssetsPage.tsx
// Good — routes.ts colocated with the pages it references
src/
  pages/
    organizations/
      vendors/
        routes.ts            # route definitions for vendors
        VendorsPage.tsx
      assets/
        routes.ts            # route definitions for assets
        AssetsPage.tsx

Existing examples that already follow this pattern: pages/organizations/compliance-page/routes.ts and pages/iam/organizations/people/routes.ts. The parent route file (routes.tsx at the app root) imports and spreads them:

import { compliancePageRoutes } from "./pages/organizations/compliance-page/routes";

// inside the route tree array
...compliancePageRoutes,

Special files

Each page folder may contain a subset of these files. Names use PascalCase matching the feature.

File Role
routes.ts Route definitions for this folder. Exports an array spread into the parent route tree. Uses lazy() from @probo/react-lazy to point at loaders / pages.
MyLayout.tsx A layout route component that renders shared chrome (Breadcrumb, PageHeader, Tabs, …) and an <Outlet />. Named with the Layout suffix — never Page — to make its role obvious at a glance.
MyLayoutLoader.tsx Loader for a layout that needs data (same pattern as MyPageLoader).
MyPageLoader.tsx Bundle entry point imported by lazy() in the route. Default export. loads data via Relay, renders a skeleton while loading, then mounts the page with queryRef. Only needed when the page reads data.
MyPage.tsx The actual page component. Receives queryRef from the loader (when data is loaded), or is the default export directly imported by lazy() when no data is needed.
MyPageSkeleton.tsx Suspense fallback rendered while the page is still receiving data. Also used as the route-level Fallback. Only needed when the page reads data.
MyPageError.tsx Error boundary rendering component for this page's error state.
_components/ Sub-components scoped to this page (see below).

Layout vs Page naming

A component is a layout when it renders <Outlet /> and exists to provide shared UI (breadcrumbs, tabs, page header) around child routes. It is a page when it renders final content with no <Outlet />.

Use the correct suffix so the role is clear from the file name alone:

// Bad — a layout route named as a "Page"
VendorDetailPage.tsx          # renders <Outlet />, wraps child routes
CookieBannerConfigPage.tsx    # renders tabs + <Outlet />

// Good — layout routes use the "Layout" suffix
VendorDetailLayout.tsx
CookieBannerConfigLayout.tsx

When do you need a loader, query, or Relay provider?

The loader / query / provider scaffolding exists to support Relay — it isn't boilerplate to copy into every page. Match the files to what the page actually does:

Page has… Files needed
No Relay data and no mutation MyPage.tsx only. lazy() imports it directly. No loader, no skeleton, no provider, no query.
A mutation only (no query) MyPage.tsx wrapped in the appropriate Relay provider (e.g. IAMRelayProvider, CoreRelayProvider). No loader, no skeleton, no query.
A query (with or without a mutation) Full pattern: MyPageLoader.tsx (provider + useQueryLoader + skeleton) → MyPage.tsx (usePreloadedQuery) + MyPageSkeleton.tsx.

Layouts follow the same rule. A layout that only renders a PageHeader and an <Outlet /> does not need a query, loader, skeleton, or provider — just a plain component imported by lazy() in routes.ts.

Example: mutation only, no query

See pages/iam/organizations/NewOrganizationPage.tsx for a reference. The page is the default export, wraps its inner component in IAMRelayProvider so the mutation has a Relay environment, and has no loader / skeleton / query.

// pages/iam/organizations/NewOrganizationPage.tsx
function NewOrganizationPageInner() {
  const [createOrganization, isCreating]
    = useMutation<NewOrganizationPageMutation>(createOrganizationMutation);
  // …
}

export default function NewOrganizationPage() {
  return (
    <IAMRelayProvider>
      <NewOrganizationPageInner />
    </IAMRelayProvider>
  );
}

Example: no data at all

// pages/organizations/cookie-banners/CookieBannerLayout.tsx
export default function CookieBannerLayout() {
  const { __ } = useTranslate();
  usePageTitle(__("Cookie Banners"));

  return (
    <div className="space-y-6">
      <PageHeader title={__("Cookie Banners")} />
      <Outlet />
    </div>
  );
}

routes.ts points lazy() at CookieBannerLayout directly — no CookieBannerLayoutLoader, no cookieBannerLayoutQuery, no skeleton.

routes.ts

Contains route objects for the current folder's feature, exported as a named array and spread into the parent. Keep imports minimal — only lazy, skeleton components, and typing.

// pages/organizations/vendors/routes.ts
import { lazy } from "@probo/react-lazy";
import type { AppRoute } from "@probo/routes";

import { VendorsPageSkeleton } from "./VendorsPageSkeleton";

export const vendorRoutes = [
  {
    path: "vendors",
    Fallback: VendorsPageSkeleton,
    Component: lazy(() => import("./VendorsPageLoader")),
  },
  {
    path: "vendors/:vendorId",
    Fallback: VendorsPageSkeleton,
    Component: lazy(() => import("./VendorDetailLayoutLoader")),
    children: [
      {
        path: "overview",
        Component: lazy(() => import("./overview/VendorOverviewPage")),
      },
    ],
  },
] satisfies AppRoute[];

MyPageLoader.tsx

The loader is the lazy bundle entry point. It sets up providers, triggers the Relay query, shows a skeleton until the query resolves, then renders the page.

// pages/organizations/vendors/VendorsPageLoader.tsx
import { Suspense, useEffect } from "react";
import { useQueryLoader } from "react-relay";

import type { VendorsPageQuery } from "#/__generated__/core/VendorsPageQuery.graphql";
import { useOrganizationId } from "#/hooks/useOrganizationId";

import VendorsPage, { vendorsPageQuery } from "./VendorsPage";
import { VendorsPageSkeleton } from "./VendorsPageSkeleton";

function VendorsPageQueryLoader() {
  const organizationId = useOrganizationId();
  const [queryRef, loadQuery] = useQueryLoader<VendorsPageQuery>(vendorsPageQuery);

  useEffect(() => {
    loadQuery({ organizationId });
  }, [loadQuery, organizationId]);

  if (!queryRef) {
    return <VendorsPageSkeleton />;
  }

  return <VendorsPage queryRef={queryRef} />
}

export default function VendorsPageLoader() {
  return (
    <CoreRelayProvider>
      <VendorsPageQueryLoader />
    </CoreRelayProvider>
  );
}

MyPage.tsx

Receives the queryRef from the loader and renders the UI. Default export so lazy() can import it.

// pages/organizations/vendors/VendorsPage.tsx
export default function VendorsPage({ queryRef }: VendorsPageProps) {
  const data = usePreloadedQuery(vendorsPageQuery, queryRef);
  return (/* … */);
}

MyPageSkeleton.tsx

A lightweight loading placeholder. Keep it free of data-fetching logic so it loads instantly.

// pages/organizations/vendors/VendorsPageSkeleton.tsx
export function VendorsPageSkeleton() {
  return (/* pulse / skeleton UI */);
}

MyPageError.tsx

Rendered by the route error boundary when the page throws.

// pages/organizations/vendors/VendorsPageError.tsx
export function VendorsPageError() {
  const error = useRouteError();
  return (/* error UI */);
}

File naming

Component files (.tsx that export a React component) use PascalCase: VendorsPage.tsx, VendorContactRow.tsx, VendorsPageSkeleton.tsx.

All other helper files (utilities, hooks, constants, configuration) use camelCase: routes.ts, useVendorFilters.ts, formatCurrency.ts, constants.ts.

Do / don't: file naming

// Bad — helper file in PascalCase
pages/organizations/vendors/FormatVendorStatus.ts
pages/organizations/vendors/UseVendorFilters.ts
pages/organizations/vendors/Routes.ts

// Good — helpers are camelCase, components are PascalCase
pages/organizations/vendors/formatVendorStatus.ts
pages/organizations/vendors/useVendorFilters.ts
pages/organizations/vendors/routes.ts
pages/organizations/vendors/VendorsPage.tsx
pages/organizations/vendors/VendorsPageSkeleton.tsx

_components folder

Sub-components that are used only by a single page live in a _components/ folder next to that page. The underscore prefix visually distinguishes them from route-segment folders.

Situation Where the component lives
Used by one page only pages/organizations/vendors/_components/
Used by multiple pages in the same feature Nearest common ancestor's _components/ (e.g. pages/organizations/_components/)
Reusable UI primitive @probo/ui package

Do / don't: component placement

// Bad — shared component buried in a single page's _components
pages/organizations/vendors/_components/StatusBadge.tsx    # also used by risks page
pages/organizations/risks/SomeRiskPage.tsx                 # imports ../../vendors/_components/StatusBadge

// Good — shared component hoisted to common ancestor
pages/organizations/_components/StatusBadge.tsx
// Bad — page-specific helper placed in a global folder
src/components/VendorContactRow.tsx     # only used by VendorContactsTab

// Good — scoped to the page that uses it
pages/organizations/vendors/_components/VendorContactRow.tsx

Child-route folder naming

Folders that contain child-route pages are named after the resource or concept the page represents, not after the UX component that currently renders them. UX patterns change (tabs become pages, drawers become routes, etc.); the resource name stays stable.

Do / don't: child-route folders

// Bad — folder named after a UI element
configuration/
  tabs/                            # "tabs" is a UI component, not a resource
    VendorOverviewTab.tsx
    VendorComplianceTab.tsx

// Good — folders named after the resource each child route represents
configuration/
  overview/
    VendorOverviewPage.tsx
  compliance/
    VendorCompliancePage.tsx

This also means child-route components use the *Page suffix (not *Tab), because they are pages in their own right — the fact that a tab bar navigates between them is an implementation detail of the parent layout.

Full example tree

Target layout for a vendors feature under pages/organizations/:

pages/organizations/vendors/
  routes.ts                        # route definitions for vendors
  VendorsPageLoader.tsx            # lazy entry — providers + Suspense + query loader
  VendorsPage.tsx                  # page component (usePreloadedQuery)
  VendorsPageSkeleton.tsx          # loading fallback
  VendorDetailLayoutLoader.tsx     # lazy entry for detail layout
  VendorDetailLayout.tsx           # layout — breadcrumbs, tabs, <Outlet />
  VendorDetailLayoutSkeleton.tsx   # detail loading fallback
  NewVendorPage.tsx                # mutation-only page — default export, wraps itself in the Relay provider
  _components/                     # sub-components used only by vendor pages
    VendorContactRow.tsx
    VendorRiskSummary.tsx
  overview/                        # child route: /vendors/:vendorId/overview
    VendorOverviewPage.tsx
  compliance/                      # child route: /vendors/:vendorId/compliance
    VendorCompliancePage.tsx
  contacts/                        # child route: /vendors/:vendorId/contacts
    VendorContactsPage.tsx