Next.js 16 App Router app for browsing and managing public transit feeds (GTFS Schedule, GTFS-Realtime, GBFS). Stack: Next 16.1.1 · React 19 · TypeScript 6 (strict) · MUI 7 + Emotion · Redux Toolkit + redux-saga · next-intl 4 · Firebase (client compat + Admin) · Sentry · Node 24.x · yarn (not npm).
When writing, reviewing, or refactoring React/Next code, apply the vercel-react-best-practices skill
(.claude/skills/vercel-react-best-practices/) — 57 rules ordered by impact. Prefer its guidance over
generic heuristics, and say when you've applied it.
One project-specific exception: see Barrel imports are fine here below.
yarn install # yarn, never npm install
yarn start:dev # dev server :3000
yarn start:dev:mock # dev server :3001 with MSW mocks
yarn lint # eslint src <- CI gate
yarn lint:fix
yarn test # jest
yarn test:ci # CI=true jest <- CI gate
yarn build:prod # next build <- the ONLY typecheck gate (see below)
yarn e2e:setup # next build + start :3001 (MSW) + Firebase auth emulator :9099
yarn e2e:setup:dev # same, but next dev (faster iteration)
yarn e2e:run # cypress run against :3001
yarn e2e:open # cypress interactive
yarn generate:api-types # OpenAPI -> src/app/services/feeds/types.ts
yarn generate:gbfs-validator-types
yarn generate:user-api-typesThere is no typecheck script and CI never runs tsc --noEmit. Type errors surface only in
yarn build:prod (and via ESLint's type-aware rules). After non-trivial type changes, run
npx tsc --noEmit yourself rather than assuming lint caught it.
CI on PRs: yarn lint + yarn test:ci for everyone; Cypress e2e + Vercel preview + Lighthouse only for
same-repo PRs (forks lack secrets). Lighthouse has no assertions — it comments but never blocks.
PR titles must follow Conventional Commits (feat: …, fix: …).
There is no src/app/layout.tsx — the real root layout is src/app/[locale]/layout.tsx. Every route
sits under the [locale] segment.
This app is 100% App Router. There is no React Router, no BrowserRouter, and no src/app/App.tsx
(react-router is not even a dependency). Any instruction claiming a legacy React Router layer is stale.
Server Components are the default: data fetching, token minting, Firebase Admin, Remote Config.
Add 'use client' only for interactivity (hooks, state, events). Server→client data crosses the boundary
in exactly two payloads, both set up in [locale]/layout.tsx: messages (next-intl) and remoteConfig.
User feature flags are not part of this boundary — they resolve entirely client-side (see below).
Never pass tokens or credentials to the client.
src/i18n/routing.ts: locales en (default, unprefixed) and fr (/fr/...), localePrefix: 'as-needed',
localeDetection: false — users must navigate to /fr explicitly. There is no src/i18n/config.ts and no
subdomain logic anywhere.
Import navigation primitives from src/i18n/navigation.ts, never from next/link / next/navigation:
// path is relative to your file — the @/ alias does not work (see File organization)
import { Link, redirect, usePathname, useRouter } from '../../i18n/navigation';Otherwise locale prefixes are dropped. Messages live in messages/{en,fr}.json; client components use
useTranslations('namespace'), server components getTranslations() / getLocale() from next-intl/server.
Next 16 renamed middleware.ts to proxy.ts. It does two jobs:
- Injects the default locale when the path has none.
- Rewrites feed-detail URLs based on the
md_sessioncookie:- authenticated non-guest →
.../[feedId]/authed/...+ headerx-mdb-authed-proxy: 1(dynamic, per-user data cache 10 min, no shared page cache) - everyone else →
.../[feedId]/static/...+ headerx-mdb-static-proxy: 1(ISR, ~14 day page TTL)
- authenticated non-guest →
The authed/layout.tsx returns notFound() unless the proxy header is present, so these routes can't be
hit directly. static/ routes must never call cookies() or headers() — that would opt them out of
static rendering. Reuse src/app/utils/proxy-helpers.ts rather than re-parsing pathnames.
See docs/feed-detail-caching-flow.md. Cache invalidation vocabulary lives in
src/app/utils/revalidate-feeds.ts; the external entrypoint is src/app/api/revalidate/ (has its own README).
The Mobility Feed API sits behind Google IAP with Identity Platform. The client's Firebase token is never forwarded to the server. Server calls mint their own credentials:
// In any Server Component / server util
const [accessToken, userContextJwt] = await Promise.all([
getSSRAccessToken(), // src/app/utils/auth-server.ts — canonical token provider
getUserContextJwtFromCookie(), // forwards end-user identity as x-mdb-user-context
]);
const feed = await getGtfsFeed(feedId, accessToken, userContextJwt);generateAuthMiddlewareWithToken(accessToken, userContextJwt?) lives in
src/app/services/api-auth-middleware.ts and is applied per-call via the use/eject pattern in
src/app/services/feeds/index.ts. Firebase Admin is initialized only through
getFirebaseAdminApp() in src/lib/firebase-admin.ts.
Full detail, env vars, and troubleshooting: docs/Authentication.md (or the mobility-auth skill).
| Firebase Remote Config | User feature flags | |
|---|---|---|
| Scope | Global | Per-user |
| Source | Firebase | GET /v1/user, resolved client-side only |
| Server read | getRemoteConfigValues() / getUserRemoteConfigValues() (src/lib/remote-config.server.ts) |
None — no server-side equivalent, see below |
| Client read | useRemoteConfig() → { config } |
useUserFeatureFlags() (src/app/hooks/useUserFeatureFlags.ts) → { flags, isResolved } |
| Define a flag | src/app/interface/RemoteConfig.ts |
src/app/interface/UserFeatureFlags.ts |
Remote Config is cached 5 min (dev) / 1 hour (prod) and has an admin email-regex bypass that flips every
boolean true (featureFlagBypass). Note RemoteConfig.ts carries a stale
// FEATUTRE BYPASS CURRENTLY DISABLED comment — the bypass is active.
User feature flags resolve entirely on the client, via a plain SWR hook keyed by the live Firebase uid
(src/app/services/user-feature-flag-service.ts) — there is no cookie, no React context, and no
server-rendered seed. UserFeatureFlagsSync (src/app/components/UserFeatureFlagsSync.tsx) mounts the hook
once, globally, in providers.tsx so flags resolve on every page, not only ones with a real consumer; the
login/signup sagas seed the SWR cache from their own GET /v1/user call (setUserFeatureFlagsCache()) so
the hook doesn't re-fetch on first render. A per-user cookie read during render can't work on statically
rendered routes (Next hands those an empty cookie store), which is why that approach — and the
POST /api/feature-flags route, BroadcastChannel push, and getServerFlags() server action that went with
it — was removed. docs/user-feature-flags.md covers the full design.
Type-safe openapi-fetch clients. Never hand-write API response types — regenerate:
- Feed API →
src/app/services/feeds/types.ts(yarn generate:api-types) - GBFS validator →
.../gbfs-validator-types.ts - User service →
src/app/services/user-service-api-types.ts
Prefer the ergonomic aliases and type guards in src/app/services/feeds/utils.ts (AllFeedType,
GTFSFeedType, GBFSFeedType, isGtfsFeedType, getLocationName, …) over raw paths[...] indexing.
- Redux Toolkit + saga for auth/profile, analytics, GBFS validator, licenses (
src/app/store/). The store is a module-level singleton, not a per-request factory. - redux-persist persists the whole root reducer except
userProfile.errorsanduserProfile.isRefreshingAccessToken. Don't put anything user-scoped and sensitive in Redux. <Provider>is mounted globally withoutPersistGateso SSG/SSR renders immediately. Wrap routes needing rehydrated state — oruseSearchParams()— incomponents/ReduxGateWrapper.tsx. Check rehydration withuseRehydrated().- React context (not Redux) for theme and Remote Config.
- SWR for the
/feedssearch (src/app/[locale]/feeds/lib/useFeedsSearch.ts) and for user feature flags (useUserFeatureFlags()— no context, see the feature-flag section above). - Typed hooks:
useAppDispatch,useAppSelectorfromsrc/app/hooks/.
profile-reducer status is a 10-value union; the load-bearing distinction is
registered vs authenticated, not just authenticated/unauthenticated.
The dominant, current convention is route-colocated code:
src/app/[locale]/<route>/{components,lib}/ (metrics uses the private _components/ prefix).
Prefer this for new work.
src/app/components/— genuinely shared UIsrc/app/services/— API clients and external integrationssrc/app/utils/,src/lib/— helpers (src/libis server-only territory)src/app/screens/— older page-level components; onlyFeed/,Feeds/,GbfsValidator/are directories, the rest are flat files. Not the pattern to extend.*.functions.tsxis not a general convention — only 2 files use it. Don't create more just to follow it; do extract pure logic so it can be unit-tested (see Testing).
Use relative imports. The @/* alias resolves in Jest but is absent from tsconfig.json paths, so
@/... breaks next build.
Next 16 ships optimizePackageImports defaults covering @mui/material, @mui/icons-material,
recharts, and date-fns, plus a modularizeImports transform for lodash. So the codebase-standard
barrel form is correct and compiles to direct imports:
import { Box, Typography } from '@mui/material'; // fine — auto-optimized
import { debounce } from 'lodash'; // fine — rewritten to lodash/debounceThe skill's bundle-barrel-imports rule still applies to any library not on that list — add such a
library to optimizePackageImports in next.config.mjs or import from its source path.
Prettier (prettier/prettier is an ESLint error, so unformatted code fails CI): single quotes in TS
and JSX attributes, semicolons, 80 columns, trailing commas. Explicit function return types everywhere —
not lint-enforced, but match it.
Notable lint rules: @typescript-eslint/no-floating-promises and no-explicit-any are errors (use
unknown + narrowing, or .then().catch() for fire-and-forget). no-unsafe-*, require-await, and
no-misused-promises are deliberately off. All of react-hooks/exhaustive-deps and rules-of-hooks are
off with a TODO — lint will not catch dependency mistakes, so reason about them yourself. yarn lint
covers src only.
- Maps:
maplibre-gl+react-map-gl+pmtiles(+@turf/center). Base wrappercomponents/Map.tsx; colors come fromuseMapConfig()because MapLibre needs concrete values, not CSS vars.LngLatTupleis[lng, lat]— MapLibre order, not Leaflet's. - Tables:
material-react-tablefor metrics andcomponents/Locations.tsx; the feeds search tables are hand-rolled MUI. Charts:recharts. - Forms:
formik+yupon auth screens,react-hook-formin the contribute wizard. Match the local file.
Client-exposed vars need the NEXT_PUBLIC_ prefix. Read through getEnvConfig(key)
(src/app/utils/config.ts) rather than raw process.env — it strips unreplaced {{KEY}} placeholders.
Server-only secrets: GOOGLE_SA_JSON, NEXT_SESSION_JWT_SECRET, GCIP_API_KEY, CRON_SECRET.
Unit tests are co-located *.spec.ts(x) under src/ (Jest + React Testing Library). Cypress e2e lives in
cypress/e2e/.
The dominant idiom is testing pure functions — 14 of 19 spec files import extracted helpers rather than rendering. Prefer extracting logic to make it testable over mounting a heavy tree.
Things already true of every Jest test — don't redo them:
next-intlis globally mocked anduseTranslations()returns the key. Assert on translation keys, never English strings.global.fetchis a barejest.fn(). SetmockResolvedValueyourself. MSW is not wired into Jest.firebase/authproviders are stubbed;TZ=UTCis forced for deterministic dates.next/serverhas a hand-rolledNextResponsefor route-handler tests.
Per-test patterns: mock src/i18n/navigation locally when a component navigates. Wrap MUI components in
<ThemeProvider theme={theme}> — there is no shared renderWithProviders helper, and you should not
wrap in the app's <Providers> (it starts MSW and Firebase). For route handlers and server-only modules,
add the docblock pragma:
/**
* @jest-environment node
*/If you add an ESM-only dependency that a tested module imports, add it to transformIgnorePatterns in
jest.config.ts or tests fail with "Cannot use import statement outside a module".
E2E: yarn e2e:setup then yarn e2e:run. Auth uses the Firebase emulator on :9099 —
cy.createNewUserAndSignIn() for the real path (it wipes all emulator accounts), or
cy.injectAuthenticatedUser() to dispatch straight into window.store. Mock data comes from MSW
(src/mocks/handlers.ts, fixtures shared from cypress/fixtures/) plus per-test cy.intercept().
Select by data-testid.
One test is currently skipped: session-renewal in cypress/e2e/userFeatureFlags.cy.ts — the renewal
interval doesn't fire under CI's next start.
- Legacy route hack: in
src/app/[locale]/feeds/[feedDataType]/page.tsxthefeedDataTypeparam is actually a feedId. It looks up the feed and redirects to the canonical/feeds/{data_type}/{feedId}. Don't "fix" the naming. docs/feed-detail-caching-flow.mdwrites the cookie assession_md; the code usesmd_session.isNotificationsEnabledexists in both flag systems, and the live consumer (ClientSubscribeControls.tsx) reads both:useRemoteConfig()gates whether the feature is live at all,useUserFeatureFlags()gates whether this specific user is entitled to it. Real duplication, not a doc error.src/mocks/data/*.jsonlook like dead duplicates ofcypress/fixtures/*.- Worktrees:
yarn new-worktree feat/x/yarn remove-worktree feat/x(copies.env*, hard-linksnode_modules).
| Doc | What it covers |
|---|---|
docs/Authentication.md |
GCIP/IAP token flow, md_session, env vars, mock mode, troubleshooting |
docs/user-feature-flags.md |
Per-user flags: cookie + BroadcastChannel, adding a flag, design rationale |
docs/feed-detail-caching-flow.md |
Feed-detail caching and revalidation (Mermaid) |
docs/ssg-initial-flow.md |
Build-time SSG and hydration path (Mermaid) |
src/app/api/revalidate/README.md |
Revalidate endpoint request/response contract |
.github/copilot-instructions.md is retained for GitHub Copilot during the migration. This file is the
source of truth — the Copilot file contains stale paths (src/app/App.tsx, src/i18n/config.ts,
non-[locale] routes) and predates the user feature-flag system. If you change project conventions,
update both.