The COEQWAL Turborepo is a monorepo for the Collaboratory for Equity in Water Allocation (COEQWAL) project. It facilitates the development, management, and deployment of applications and packages that support equitable water management decisions by combining community input, computational models, and open data.
This repository uses Turborepo to streamline development workflows, allowing shared code, efficient builds, and cross-project collaboration. A key concept in a Turborepo is that there is a directory for apps and a directory for packages. Apps are standalone apps that can be developed independently and imported into other apps or built and run separately. Packages are components that can be shared between apps. Both are "workspaces," to use the Turborepo terminology, and can be connected by setting up exports and imports in their respective package.json files.
Dependencies and configurations set at the root level are overriden by local dependencies and configurations. For example, if you'd like to set a different linting configuration or a different dependency version for a specific app, you can configure these using that app's package.json and configuration files.
- Overview
- Stack
- Key architecture patterns
- Installation
- How to run
- How to deploy
- Do local dev builds feel sluggish?
- Finding dead code
- Changes from the standard Turborepo
- React StrictMode
- SSG and hydration boundaries
- Adding a new app to the Turborepo (monorepo)
- Adding a new package
- Regular Turborepo maintenance (for lead dev)
- Roadmap
The repository is managed with Turborepo + pnpm workspaces and split into two top-level directories:
apps/standalone Next.js applicationspackages/shared libraries consumed by the apps
apps/mainThe primary COEQWAL website. A Next.js 15 (App Router) application with an interactive Mapbox map, a scenario explorer, data visualizations, and a three-tab system (Learn / Explore / Share). All pages are statically exported.apps/storyline-flowA standalone storyline app focused on water flow narratives (Next.js 15, static export).apps/storyline-climateA standalone storyline app focused on climate scenarios (Next.js 15, static export).
@repo/uiShared UI component library built on MUI v7 and Emotion. Exports components (header, panels, chips, tooltips, inputs, modals), a centralized MUI re-export entry point, theme configuration, and UI hooks.@repo/dataData fetching and caching layer using SWR. Provides COEQWAL API types, fetch functions, React hooks, cache key management, aDataProvider, and static GIS data (GeoJSON files).@repo/vizD3-based visualization components for water data: bar charts, line charts, percentile band charts, rose charts, spill charts, parallel line plots, glyph components, and shared D3 utilities.@repo/mapMapbox GL mapping components via react-map-gl. Includes the coreMapcomponent,MapProvidercontext, geocoding control, declarative layer management hooks, spatial query hooks (point-in-polygon), and transition utilities.@repo/stateShared state management utilities. Re-exports Zustand and Immer, and provides a shared drawer store.@repo/motionAnimation wrapper around Framer Motion.@repo/scrollytellingScrollytelling primitives (components and hooks) layered onreact-scrollamaand@repo/motion. Used by the storyline apps.@repo/i18nInternationalization provider and translation hooks.@repo/utilsGeneral utilities including anErrorBoundarycomponent.@repo/typescript-configShared TypeScript configuration presets (base, Next.js, React library).@repo/eslint-configShared ESLint configuration.
The main app (apps/main) has three routes:
/Home page with a video hero, intro section, and the three-tab system overlaid on a persistent Mapbox map/aboutProject information, partner logos, and contact details/dataScenario data downloads (ZIP and CSV)
Key features live in apps/main/app/features/:
map/Mapbox instance with base layers (rivers, basins), visualization layers (outcomes, tier markers), overlay panels, camera presets, and its own Zustand storescenarioExplorer/Multi-view scenario explorer with list, comparison, equity, and data explorer viewsscenarios/Scenario selection components and data hooksglossary/Floating glossary paneltooltips/Tier tooltips, map feature tooltips, and scroll tooltips
Styling uses MUI v7 with Emotion (CSS-in-JS via the sx prop and a shared theme from @repo/ui/themes). This choice was made so facilitate design system collaboration. It does however greatly expand the hydration boundary for the site, effectively limiting our SSR options. That said, we are using i18n, map layers, and d3 extensively in the site, which also greatly expands our hydration boundary.
State management combines Zustand stores (map state, scenario explorer state) with React Context (tab state) and URL query-parameter sync for the active tab.
| Layer | Technology |
|---|---|
| Framework | Next.js 15 (App Router), React 19, TypeScript 5.8 |
| Build | Turborepo, pnpm 10, Node 22 |
| UI | MUI v7 + Emotion, SASS |
| State | Zustand (with Immer), React Context |
| Data fetching | SWR, native fetch |
| Maps | Mapbox GL + react-map-gl, Turf.js |
| Charts | D3 v7 (custom components in @repo/viz) |
| Animation | Framer Motion |
| Scrollytelling | react-scrollama, custom @repo/scrollytelling |
| Drag and drop | @dnd-kit |
| Deploy | AWS Amplify (static export) |
The main app wraps its component tree in a DataProvider (SWR) that communicates with the external COEQWAL API at https://api.coeqwal.org/api. Typed hooks in @repo/data (such as useScenarios, useTiers, useReservoirPercentiles, and others) abstract the API calls and manage caching via SWR cache keys. File downloads are handled through a separate AWS API Gateway endpoint. There are no Next.js API routes in the repo. All apps are statically exported.
Zustand stores manage UI state for the map (apps/main/app/features/map/store.ts) and the scenario explorer (apps/main/app/features/scenarioExplorer/store.ts). React Context is used for the map API (MapContext), tab navigation (TabsProvider), chart grid layout (ChartGridContext), and internationalization (TranslationProvider). The active tab is also synced to URL query parameters.
The main app renders a Mapbox map that persists behind all scrolling and tabbed content. A LayerOrchestrator manages base layers (basins, rivers, directional arrows) and visualization layers (scenario outcome polygons, tier markers, points of interest). The map is dynamically imported with ssr: false to avoid bundling Mapbox GL on the server.
All apps use output: "export" in their Next.js config, producing fully static sites deployed to AWS Amplify. This means no server-side rendering at request time, no API routes, and no middleware. The NEXT_PUBLIC_MAPBOX_TOKEN environment variable is required at build time.
The @repo/viz package contains custom D3 chart components covering a wide range of chart types. These are purpose-built for water data and scenario comparison.
Under construction
Node.js: install the version pinned in .nvmrc (currently 22.x, see Node version cadence below). With nvm or fnm, nvm install / nvm use read .nvmrc automatically:
nvm install
nvm use
node -v # should print v22.x.yIf you don't use a version manager, install any Node 22 LTS patch through your system package manager.
pnpm: install via Corepack. The version is pinned in the root package.json packageManager field (currently pnpm@10.0.0):
corepack enable
corepack prepare pnpm@10.0.0 --activate
pnpm -v # should print 10.0.0Amplify installs pnpm with npm install -g pnpm@<version> in its build container (see the per-app stanzas in amplify.yml at the repo root). When bumping pnpm, update the root package.json packageManager field and the npm install -g pnpm@X lines in amplify.yml together.
Clone the repository, cd into the repo, and install dependencies.
git clone https://github.com/berkeley-gif/coeqwal-website.git
cd coeqwal-website
pnpm installSee package.json for scripts. Note that after running the build scripts, the builds will appear in the .next/ directory of each app. You can run the built app by running pnpm start in the app's directory.
Here is how to explicitly run the dev script:
pnpm devTo run a specific app (e.g., main), navigate to its directory and start it:
cd apps/main
pnpm devor
pnpm dev --filter mainThis is recommended while developing because running the whole pnpm dev will slow down your dev builds and hot reload because it will start every package/app that has a dev task and their watchers.
You can also add scripts to the root package.json like:
"dev:main": "pnpm --filter main dev",if you find that convenient. Feel free to use shorthand for apps with long names:
"dev:sf": "pnpm --filter storyline-flow dev",To build, and before pushing to github:
pnpm format
pnpm lint
pnpm buildor
pnpm format --filter=main
pnpm lint --filter=main
pnpm build --filter=mainThe COEQWAL website is hosted on AWS Amplify as the independent apps that share this monorepo. The dispatcher is a GitHub Actions workflow (Deploy to Amplify) which assumes a GitHub OIDC (GitHub OpenID Connect) role (coeqwal-website-amplify-deploy-role) and calls aws amplify start-job for each selected app.
"Shared workspaces" are packages/**, pnpm-workspace.yaml, turbo.json, and the root package.json.
Summary There are two kinds of changes, and they behave differently:
- App-only changes (files under
apps/<x>/**) deploy automatically. - Shared-workspace changes can affect every app at once, so they never auto-deploy. They go through a PR, and each app owner redeploys their own app when ready.
Quick rules
- Editing one app? Open a PR into
dev. When it merges (or when you push todev), that one app auto-deploys. Markdown-only edits never deploy an app. - Editing shared code? Open a PR. Merging it updates the source on
devbut doesn't deploy an app. Affected app owners get a notification listing their apps and the deploy link, and they redeploy when ready. Pushing shared code straight todevwithout a PR fails CI on purpose. - Need to deploy right now (for example, to pick up a shared change)? Go to the Actions tab -> Deploy to Amplify -> Run workflow, then choose your app and
dev.
When in doubt, the table below is the full reference for every trigger, with the exact files that configure each behavior.
| Trigger | What happens | Where it is configured |
|---|---|---|
Push or PR merge to dev touching one or more apps/<x>/** (non-Markdown) |
Each app with changes auto-deploys. | deploy-amplify.yml: on.push.paths, dorny/paths-filter filters, and the compute step that maps changed apps to deploy targets. |
PR into dev touching shared workspaces (non-Markdown) |
Notify package changes posts a sticky comment listing affected apps (discovered from apps/*/package.json), their owners (from CODEOWNERS entries, when active), and a per-app dispatch link. Never triggers a deploy. Merging a shared-package PR to dev changes the source on dev, but it will not deploy to the site without manual deployment. |
notify-package-changes.yml: on.pull_request.paths; impact list from compute-affected-apps.mjs. |
Push to dev (not via PR) touching shared workspaces or pnpm-lock.yaml (non-Markdown) |
Enforce package-PR rule fails the run. The pusher gets a "workflow failed" email. Please open a PR for shared workspaces. | enforce-package-pr.yml: on.push.paths and the "Inspect push commits for PR provenance" step, which runs check-package-pr-provenance.mjs (retries transient GitHub API failures; an exhausted-retries failure asks for a re-run instead of reporting a violation). |
| Anything touching only Markdown | Nothing. Markdown is exempt from both notify and enforce. | deploy-amplify.yml: !apps/<app>/**/*.md in on.push.paths and in dorny/paths-filter. enforce-package-pr.yml: !**/*.md / !**/*.mdx in on.push.paths and the markdown filter in check-package-pr-provenance.mjs. |
Anything touching only pnpm-lock.yaml |
No notify, no deploy. A lockfile-only PR merge to dev is a non-event. A direct (non-PR) lockfile push to dev still fails Enforce package-PR rule (see the row above). |
Omitted from notify-package-changes.yml on.pull_request.paths and from compute-affected-apps.mjs; not in deploy-amplify.yml on.push.paths. But pnpm-lock.yaml is in enforce-package-pr.yml on.push.paths and the watched paths of check-package-pr-provenance.mjs. |
| Deploy to Amplify via GitHub Actions tab -> Run workflow | Manually deploys the chosen app(s) on dev. |
deploy-amplify.yml: on.workflow_dispatch inputs (apps, branch) and the deploy job MAP lookup. |
| Console name | Repo path | Custom domain (today -> launch) |
|---|---|---|
coeqwal-website-dev |
apps/main |
dev.coeqwal.org -> coeqwal.org |
storyline-flow-dev |
apps/storyline-flow |
flow.coeqwal.org |
storyline-climate-dev |
apps/storyline-climate |
climate.coeqwal.org |
The Amplify build-spec is the repo-root amplify.yml. The online Amplify Console build spec has been entered in the console for each app as a fallback that mirrors each app's matching stanza. If the root file is ever removed, Amplify falls back to the console version.
A PR into dev that touches packages/**, pnpm-workspace.yaml, turbo.json, or root package.json triggers Notify package changes. The script compute-affected-apps.mjs diffs the PR against dev, discovers the monorepo's apps from apps/<name>/package.json on disk, and reads each app's package.json to find which apps depend on each changed package. It then posts a comment on the PR (header package-deploy-impact) listing the affected apps, their owners, and a per-app dispatch link. Owners come from active /apps/<name>/ entries in .github/CODEOWNERS; while those rules are commented out (code-owner review paused), apps show as unassigned and the comment still posts. The script is covered by pnpm test:ci-scripts, which runs in the CI static-checks job.
pnpm-lock.yaml is intentionally excluded from the trigger paths and from the script's impact set because lockfile churn (regenerations, transitive bumps, dependabot) is too noisy.
After a package PR merges to dev, each affected app's CODEOWNER decides whether to ship the change into their app. Three situations:
- Already validated locally (e.g., the package PR was opened from your branch and you ran your app against it): dispatch Deploy to Amplify for your app on
dev. Done. - Not yet validated: pull
dev, runpnpm install, runpnpm dev --filter <your-app>, click around. Push when satisfied. - App is feature-complete and dormant: do nothing. The previously-deployed build keeps running until somebody explicitly redeploys.
Steps 1, 4, and 5 are repo edits. Make them on a branch and merge them into dev before the smoke test in step 6, because that test runs against whatever is on dev. Steps 2 and 3 are AWS-side (Amplify Console and IAM). The App ID created in step 2 is needed by the IAM policy (step 3) and the workflow MAP (step 4).
- Root amplify.yml - author the build spec first. Add an
applications:stanza copy-pasted from one of the existing three, withappRoot: apps/<name>,baseDirectory: apps/<name>/out, and--filter=<name>. Keep the samecache:block andNODE_VERSIONpin. The console step mirrors this stanza as its fallback, so it has to exist first. - Amplify Console - create the app as a static (
WEB) host. These apps are static exports (output: "export"in eachnext.config, emitted toout/), so they should run on Amplify's staticWEBplatform, not the Next.jsWEB_COMPUTE(server) platform. Amplify's framework auto-detection, when it sees a Next.js repo, generally defaults the new app toWEB_COMPUTE, which expectsbaseDirectory: .nextand will not serve ourout/, so the platform has to be forced back toWEB. The console paths below are the expected locations. Labels can shift between Amplify Console versions, so navigate around if they have changed:- a. Amplify Console -> Create new app -> GitHub, authorize, then choose
berkeley-gif/coeqwal-websiteand branchdev. - b. Check My app is a monorepo and enter the app root
apps/<name>. The console setsAMPLIFY_MONOREPO_APP_ROOT=apps/<name>for you. - c. Force the platform to
WEB. Amplify auto-detects Next.js and creates the app asWEB_COMPUTE, which expectsbaseDirectory: .nextand serves nothing from ourout/. After the app is created, flip it back with the AWS CLI (the console may not expose a reliable platform toggle once Next.js is detected, so the CLI is the dependable way):The AWS console should now show the app asaws amplify update-app --app-id <appId> --platform WEB # verify (must print WEB): aws amplify get-app --app-id <appId> --query 'app.platform' --output text
WEB. - d. Disable auto-build on
dev(App settings -> Branch settings -> turn off automatic builds). Every deploy is driven by theDeploy to Amplifyworkflow, never by Amplify's own Git trigger. - e. Paste a per-app fallback inline build spec mirroring the stanza you added in step 1.
- f. Note the App ID. You need it for the IAM policy (step 3) and the workflow
MAPlookup (step 4). - g. Set the app's build-time environment variables (App settings -> Environment variables). Any
NEXT_PUBLIC_*value the app reads at build time goes here. Today that meansNEXT_PUBLIC_MAPBOX_TOKENif the app renders a map. Each such variable must also be listed in root turbo.json (globalEnvand thebuildtask'senv), or Turbo strips it from the build environment and the value reaches the bundle asundefined.NEXT_PUBLIC_MAPBOX_TOKENis already declared there. A var that is new to the monorepo needs that declaration added.
- a. Amplify Console -> Create new app -> GitHub, authorize, then choose
- IAM: extend the
amplify-start-jobpolicy on rolecoeqwal-website-amplify-deploy-roleto grantamplify:StartJobandamplify:GetJobon the new app's job resources, i.e. addarn:aws:amplify:us-west-2:<acct>:apps/<appId>/*to the policy'sResourcelist (the/*reaches the branch and job sub-resources that these actions act on. The bareapps/<appId>ARN is not enough). This policy is deliberately scoped to each app's ARN, so a new app is denied deploys until its ARN is added here. The reason theResourcehas to name the job sub-resource is thatamplify:StartJobandamplify:GetJobare resource-scoped to thejobsresource type (arn:...:apps/${AppId}/branches/${BranchName}/jobs/${JobId}). See the AWS Amplify Service Authorization Reference (thejobsresource type, and theStartJob/GetJobrows) and How Amplify works with IAM. - .github/workflows/deploy-amplify.yml: register the new app in the deploy workflow according to the process below. All of the edits below are made in this file (
.github/workflows/deploy-amplify.yml):- Dispatch menu: add
- <name>toworkflow_dispatch.inputs.apps.options. - "Deploy all" option: add
<name>to theall)case'sAPPS='[...]'array so theallchoice includes it. - Push trigger paths: add
apps/<name>/**and!apps/<name>/**/*.mdto the workflow-levelpush.paths. - Path filter: add a matching block to the
dorny/paths-filterfiltersmap (<name>:withapps/<name>/**and!apps/<name>/**/*.md). The step usespredicate-quantifier: 'every', so the!*.mdline acts as an exclusion. - Compute step,
env:add<NAME>_CHANGED: ${{ steps.filter.outputs.<name> || 'false' }}to thecomputestep'senv:block. - Compute step, dispatch
case: add<name>) APPS='["<name>"]' ;;to thecase "$SELECTION"block. - Compute step, push append: add
[[ "$<NAME>_CHANGED" == "true" ]] && APPS=$(jq -c '. + ["<name>"]' <<<"$APPS")to the push branch, so a push touching the app auto-deploys it. Use the same<NAME>_CHANGEDname as theenv:entry above. Without this line the app never auto-deploys on push (the dispatchcasealone only covers manual runs). - App-ID lookup: add
["dev:<name>"]="d<appId>"to theMAPin the lookup step. - Do not add a production branch entry yet. That comes when production is wired.
- No new GitHub secret or variable is needed: the workflow authenticates with the shared
AMPLIFY_DEPLOY_ROLE_ARNActions variable (repo Settings -> Secrets and variables -> Actions -> Variables), which every app reuses.
- Dispatch menu: add
- .github/CODEOWNERS (optional): add
/apps/<name>/ @owner1 @owner2to show owners in the package-impact comment. The notify workflow tracks the app automatically onceapps/<name>/package.jsonexists; without an active CODEOWNERS entry the app shows as unassigned. - Smoke test: once steps 1 and 4 are merged to
dev, dispatch Deploy to Amplify for<name>ondev, confirmSUCCEED. Then push a one-line edit underapps/<name>/, confirm only<name>auto-deploys. - Custom domain: In Amplify Console (Hosting -> Custom domains), attach
<sub>.coeqwal.org. Amplify provisions HTTPS through AWS Certificate Manager (ACM). Complete ACM DNS validation: add the CNAME records Amplify shows in Route 53 (or wherevercoeqwal.orgDNS is hosted) and wait until the certificate is Issued andhttps://<sub>.coeqwal.orgloads. This step is DNS and certificate setup only, not a deploy or smoke test (those are step 6).
(especially if you have been doing data intensive work)
- To clean bloated Turbo and app-level NextJS caches
(again, using
mainapp as example):
rm -rf .turbo/cache
rm -rf .turbo apps/main/.nextSee also the clean scripts in the root package.json.
The repo uses knip to find unused files, exports, and dependencies across the workspaces.
pnpm dead-codeKnip prints its findings straight to the terminal and exits with a non-zero status when it finds anything, so the command could look "failed" (red) even though nothing is wrong. If it is too long to read in the console, you can pipe the output, or pipe to read just a slice of it, for example pnpm dead-code | grep apps/main.
The terminal output is grouped into sections, which vary with what is found. A full run currently shows: unused files, unused dependencies, unused devDependencies, unresolved imports, unused exports, unused exported types, and duplicate exports. Because knip scans all apps and packages together, a file or symbol that is only used by another app (or a shared @repo/* package consumed by an app) will not be reported as unused.
Always review findings before removing anything. The sections below describe what the tool can and cannot tell you. Don't over-trust the report.
The export-related sections in particular are large and mostly not dead code. Two categories dominate the noise: duplicate exports (the repo's convention of exporting both a named and a default symbol from the same file) and the public API of @repo/* packages (exports with no in-repo importer, which does not make them dead). Treat both as expected noise rather than removal candidates.
Knip analyzes the static import graph. It knows which files import which other files and symbols. It does not run the code, so two important categories slip past it:
- Runtime-dead code that is still statically imported. A component can be imported and rendered in the JSX yet never actually appear, for example because it is gated behind a prop or flag that is always false. Knip sees the import and the render, so it reports the file as used. Deciding whether such code is truly live requires reading the call chain by hand, not the report. A concrete example in this repo is the basin inflow arrows: they are imported and rendered by
BaseLayers, so knip considers them used, even though whether they ever draw depends on runtime state. - References built from strings or wired in at build time. Dynamic imports assembled from string fragments, registry lookups keyed by string, and build-time config references are all invisible to the static graph (see the false positives below).
A barrel is an index.ts whose job is to re-export symbols from neighboring files. Knip marks a file as used if any reachable module imports it, so when a barrel re-exports a file and the barrel is imported anywhere, that file counts as used even if nothing actually consumes the re-exported symbol. The deadness then shows up under unused exports (the redundant re-export line), not under unused files. This is why the unused-files list can look short while real dead code hides one level down.
To find files that are masked this way, there are two reliable methods:
- Prune and re-run. Remove the unused re-export lines from a barrel, then run
pnpm dead-codeagain. Any file that was reachable only through a removed line now appears under unused files. - Trace imports by path. For each re-exported file, search the codebase for direct imports of that file. If the only importer is the barrel, the file is dead.
Note that knip's ignore option does not help here: ignored files are still treated as importers, so ignoring a barrel does not reveal the files it was masking. This was verified directly. Use the two methods above instead.
- Build-time loaders referenced by string, such as
apps/main/geojson-loader.cjs(named innext.config.js). This is not dead code. - Framework and tooling dependencies that are not imported directly, such as
sass(compiled by Next), the Emotion packages (used through MUI),@turbo/genand the turbo generator config (used byturbo gen), and@types/*packages.
When a finding is confirmed to be a false positive of the first kind (a real entry point the static graph misses), record it in knip.json so future runs stay accurate. For example, geojson-loader.cjs is declared there as an entry for apps/main. Do not add genuinely dead code to the ignore lists just to quiet the report.
This Turborepo has been customized to meet the needs of the COEQWAL project. Key changes include:
react,react-dom, all their types,typescript,@types/node,prettier,eslint,turbo, andsassare installed at the root to ensure consistency across apps and reduce duplication. Compare the dependencies in the rootpackage.jsonwith thepackage.jsonin the individualappsandpackagesdirectories for details. Note that apps must installnext(because packages wouldn't use next, so it doesn't make sense to install it at the root...maybe). We need to keep thenextversions in sync.
- The shared
eslint-config,typescript-configanduiare standard for Turborepo setups, but these can be customized for the project. - The Viz Team should feel free to set up packages to support their common work.
The main app has React StrictMode enabled in apps/main/app/layout.tsx. StrictMode is a development tool that helps catch common bugs early.
- Catches impure renders: Identifies components that produce different output on re-render
- Detects missing effect cleanup: Finds effects that don't properly clean up subscriptions, timers, or event listeners
- Warns about deprecated APIs: Alerts you to legacy React patterns that will break in future versions
- Improves code quality: Encourages patterns that work well with React's concurrent features
StrictMode intentionally double-invokes certain functions to help detect side effects:
- Double console logs: You'll see console.log statements appear twice in development
- Effects run twice:
useEffectcallbacks run twice to verify proper cleanup - Render functions called twice: Components render twice to detect impure renders
These double invocations only happen in development mode Production builds are unaffected.
// Development with StrictMode:
"Component mounted" // First invocation
"Component mounted" // Second invocation (StrictMode check)
// In production:
"Component mounted" // Single invocation
We encourage enabling StrictMode in other apps to maintain code quality. If you choose to do so, here are the steps:
- Add to your
layout.tsx:
import { StrictMode } from "react"
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
<StrictMode>{/* your providers and content */}</StrictMode>
</body>
</html>
)
}That's it! If you encounter issues, you can temporarily disable StrictMode by removing the wrapper, fix the underlying problem, then re-enable it.
The main app uses Next.js App Router with static export (SSG). Understanding the Server Component / Client Component boundary is essential for maintaining performance.
MUI's sx prop uses Emotion CSS-in-JS, which processes styles at runtime. When you use theme functions, that code must run in the browser, requiring a Client Component.
Strategies we use:
-
Inline known values: If it's beneficial to make a component a static layout component, for example if it is the ancestor to many other components, we hardcode theme values with comments referencing the source:
// Value from theme.zIndex.pageContent (inlined for Server Component) zIndex: 10,
-
Explicit hydration boundaries: Client providers are rendered directly inside Server Components. Layout-wide providers (theme, translations, data,
TabsProvider) live inapp/layout.tsx. Home-page-only providers (MapProvider) are rendered inline inapp/page.tsx. -
Dynamic imports for heavy libraries: The Mapbox map is dynamically imported with
ssr: falseto reduce initial bundle size.
- Add
"use client"when: Component uses React hooks, browser APIs, or event handlers - Keep as Server Component when: Component is purely presentational with static or inlined values
- Use dynamic imports for: Heavy libraries that aren't needed for initial render (maps, charts)
- Document substituted inlined values: Always comment where the value comes from (e.g.,
// from theme.zIndex.pageContent)
MUI supports CSS variables mode (cssVariables: true in theme config), which would allow Server Components to use theme values via var(--mui-zIndex-pageContent). This is a potential future optimization.
To add a new app, cd into the apps directory and run
pnpm dlx create-next-app@latest <app name>To maintain consistent structure for all apps, for configurations, choose No for TailwindCSS, src/ directory, and import alias; otherwise, choose Yes.
This generator should create your directory and install necessary files, configurations, and dependencies. Then go to the root level and run:
cd ../
pnpm installTo make sure everything is linked correctly. Run pnpm dev and pnpm build to make sure the installation works.
- To match the configuration with the rest of the Turborepo:
cd apps/<app name>
pnpm remove react react-dom typescript @types/node @types/react @types/react-dom eslint eslint-config-next @eslint/eslintrcYou can use the main app's package.json as a guide.
pnpm installRun pnpm dev and pnpm build to make sure the changes are okay.
Finally, set up eslint using the eslint-config package:
pnpm add @repo/eslint-config -D --workspaceReplace eslint.config.mjs with eslint.config.js like in the main app.
pnpm installAnd be sure to test the app by running pnpm dev and pnpm build.
If your installation gets messed up at any point, try
rm -rf node_modules .turbo && pnpm install && pnpm buildAdding a new package to a Turborepo involves creating a new directory for the package, setting up its structure, and configuring it to work with the rest of the monorepo.
Packages typically wouldn't use Nextjs, but they could use React. There are multiple ways to add a new package, but the most straightforward is to run:
pnpm turbo gen workspace --destination packages/<my-new-package> --type packageNameshould be@repo/<package-name>.- In 99% of cases you'll want to select
eslint-configandtypescript-configas devDependencies.
This will create a new package in the packages directory with a package.json. Tasks now are:
- Fill in the scripts and dependencies in the
package.jsonfile.nameshould be"@repo/<my-new-package>"- include
"type": "module", - scripts and dependencies should generally be as in the
mappackage. Addeslintas a devDependency, matching the root pin (currently^9.39.2). I haven't automated that yet. - refer to these packages for suggestions for the dependencies and dev dependencies.
- Add a
tsconfig.jsonfile to the package to use the shared typescript config (copy fromi18n package). - Add an
eslint.config.mjsfile to the package to use the shared eslint config (copy fromi18n package). - Set up your
srcdirectory. - Set up the appropriate exports in the
package.jsonfile. - Set up the appropriate imports in the
package.jsonfiles of the apps that will use the package. - Run
pnpm installat the root level to make sure all new packages and workspace import/exports are installed.
Ideally a quarterly review, but at least yearly:
- Keep node, NextJS, and package versions up-to-date
- Review and maintain configs
The three apps and the Amplify build images target a single Node major. The strategy is to ride the current Active LTS, then bump one major shortly after the next LTS enters maintenance. That keeps us continuously on a supported LTS without scrambling near EOL.
Current target: Node 22 LTS.
Files to touch when bumping:
- Root package.json
engines.nodeanddevDependencies.@types/node - Root .nvmrc
- This README's "Installation" section
- Root amplify.yml (the three
nvm install <n>lines and the threeNODE_VERSIONvalues, one per app stanza) - Each Amplify Console inline build spec (kept as a fallback in case root
amplify.ymlis ever removed; re-paste the matching stanza)
Validate by dispatching the Deploy to Amplify workflow against each app on dev and smoke-testing the resulting site. Backout: revert the commit, re-paste the previous YAML into the console.
Outstanding items, in no particular order:
-
Sync
nextversions across apps. The apps currently drift (some on^15.5.9, some on^15.2.1). Bump the stragglers so all apps share onenextversion. Adoptingcatalog:(below) would make this enforceable going forward. -
Production cutover at launch. Per-app: create the production git branch, create the production Amplify app, add the branch to the GiHub Action workflow's
branch:dropdown, add theMAProw, smoke-test, cut over the custom domain via ACM (AWS Certificate Manager). Enable Amplify Firewall (WAF) on the production apps. Narrow CORS and presign allowlists from*to production hostnames. -
(Nice to have) pnpm catalog. Adopt
catalog:inpnpm-workspace.yamlto keep shared library versions (react,react-dom,next,typescript,turbo) in sync across workspaces. Apps can opt out by pinning a specific version in their ownpackage.json. -
Node 24 LTS cadence. Bump from Node 22 to Node 24 shortly after Node 24 enters Maintenance LTS in October 2026 (see Node version cadence; Node 24 EOL is April 2028 per the official release schedule).