Skip to main content

Routing Architecture

Next.js App Router Structure

The application uses the Next.js 14 App Router with three top-level layouts and several nested route groups.

app/
├── layout.tsx # Root layout (fonts, providers)
├── globals.css

├── (base-layout)/ # No URL segment — pages outside any team scope
│ ├── page.tsx # / (root / team chooser)
│ ├── account/page.tsx # /account
│ ├── no-access/page.tsx # /no-access
│ └── ...

├── auth/ # Public auth flows
│ ├── sign-in/page.tsx
│ ├── sign-up/page.tsx
│ ├── callback/route.ts
│ ├── confirmations/...
│ └── restricted/page.tsx

├── [primaryTeam]/ # Dynamic team segment
│ ├── (team-layout)/ # Team-scoped pages
│ │ ├── layout.tsx # Calls getRoutePermissions({ teamId }) + redirects
│ │ ├── page.tsx # /[primaryTeam] (team dashboard)
│ │ ├── campaigns/... # /[primaryTeam]/campaigns
│ │ ├── members/... # /[primaryTeam]/members
│ │ ├── teams/... # /[primaryTeam]/teams (super-admin)
│ │ ├── roles/... # /[primaryTeam]/roles
│ │ ├── permission-keys/...
│ │ ├── permission-set/...
│ │ ├── credentials/...
│ │ ├── voter-search/page.tsx
│ │ ├── admin-voter-search/page.tsx
│ │ ├── all-turn-ins/... # team-wide accounting fallbacks
│ │ ├── rates/...
│ │ ├── transactions/...
│ │ ├── regions/...
│ │ └── circulator/ # Circulator dashboards
│ │ ├── by-campaign/page.tsx
│ │ └── by-turn-in-date/page.tsx
│ │
│ └── (campaign-layout)/ # Campaign-scoped pages
│ └── campaign/[slug]/ # Nested dynamic segment
│ ├── layout.tsx # Calls getRoutePermissions({ teamId, campaignId })
│ ├── page.tsx # /[primaryTeam]/campaign/[slug]
│ ├── add-signatures/...
│ ├── signatures/...
│ ├── petitions/...
│ ├── petition-format/...
│ ├── dashboard/...
│ ├── admin-dashboard/...
│ ├── validators-view/...
│ ├── circulators-view/...
│ ├── households-view/...
│ ├── sub-teams/...
│ ├── transactions/...
│ ├── turn-in/...
│ ├── rates/...
│ ├── regions/...
│ └── no-access/page.tsx

└── api/ # Route handlers
├── essearch/route.ts
├── essearch-common/route.ts
├── duplicate/route.ts
├── charts/[id]/route.ts
└── permissions/revalidate/route.ts

Route patterns

1. Static routes

Auth flows under app/auth/* and the root chooser (base-layout)/page.tsx.

2. Dynamic segments

  • [primaryTeam] — team slug. Read from params in server components or via useParams() in client components.
  • [slug] — campaign id (often a slug). Sits under (campaign-layout)/campaign/[slug].
  • [id] — record id used in nested routes like permission-set/[id]/edit.

3. Route groups

Folders wrapped in parentheses (e.g. (team-layout), (campaign-layout)) group related routes under a shared layout.tsx without appearing in the URL.

4. Layout composition

Every nested layout inherits the parents:

RootLayout
└─ (base-layout) | (team-layout) | (campaign-layout)

The team and campaign layouts are responsible for permission gating before their children render. See Middleware & Route Protection.

Accessing route parameters

Server Components

export default async function Page({
params,
}: {
params: { slug: string; primaryTeam: string };
}) {
const campaign = await getCampaignBySlug(params.slug);
}

Client Components

"use client";
import { useParams } from "next/navigation";

export function MyComponent() {
const params = useParams<{ primaryTeam: string; slug: string }>();
// params.primaryTeam, params.slug
}

Search params

export default async function Page({
searchParams,
}: {
searchParams: { [key: string]: string | string[] | undefined };
}) {
const step = searchParams?.step ?? "1";
}

Search params are also used to encode shareable table state (filters, sorting, pagination) — the table primitives in src/components/tables/common/ read them via useSearchParams() on the client and searchParams on the server.

Special pages

PathPurpose
/no-accessTeam-level access denial
/[primaryTeam]/campaign/no-accessCampaign-level access denial (inside the campaign layout)
/auth/restrictedAccount is blocked
/auth/sign-inSign-in target for unauthenticated requests

The "no-access" pages render a <RefreshPermissionsButton /> (src/components/common/refresh-permissions-button.tsx) that calls POST /api/permissions/revalidate so a freshly granted permission becomes effective without a full sign-out.