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.
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/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, Flubber (shape morphing) |
| 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 three apps are statically exported and rely entirely on client-side fetching to external services.
Zustand stores manage complex 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 three 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: line, bar, radar, sankey, dumbbell, percentile bands, dot strips, parallel plots, heatmaps, distribution glyphs, and more. These are purpose-built for water data and scenario comparison.
Under construction
Node.js: Ensure you have Node.js version 22.x installed. Use nvm or Volta for version management.
nvm install 22.21.1
nvm use 22.21.1pnpm: Install pnpm using Corepack locally (included in Node.js 22.x).
corepack enable
corepack prepare [email protected] --activateNote (in case you were reading the amplify.yml and wondering): Locally it's easiest to use Corepack. AWS Amplify instead installs pnpm globally in the container they use to run the build.
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=main(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.
This Turborepo has been customized to meet the needs of the COEQWAL project. Key changes include:
react,react-dom, all their types, andtypescript,@types/node, andprettierare 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: We use wrapper components (
ClientProviders.tsx) to establish clear boundaries between Server and Client Components. -
Dynamic imports for heavy libraries: The Mapbox map is dynamically imported with
ssr: falseto reduce initial bundle size.
page.tsx (Server Component)
└── ClientProviders (Client boundary - provides MapProvider, TabsProvider)
├── SkipLink
├── Header
├── DynamicMap (dynamic import, ssr: false)
├── FloatingGlossary
└── MainContent (Server Component - inlined theme values)
├── IntroSection (Client - uses hooks)
├── SmoothTabs (Client - uses hooks)
└── TabPanels (Client - uses hooks)
- 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
mapori18npackage. Note that you should write ineslint": "^9.15.0as a devDependency. 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
- In the near future, I'd like to implement pnpm cataloging to keep package versions in sync. This looks like having a
pnpm-workspace.yamlthat contains packages and versions like so:
catalog:
react: 19.2.1
react-dom: 19.2.1
next: 15.5.9
typescript: 5.8.2
turbo: 2.6.3
Then in each app/package that needs them:
{
"dependencies": {
"react": "catalog:",
"react-dom": "catalog:",
"next": "catalog:"
}
}
This can be optional and per app. If you are a developer responsible for making sure an app "stays in place" over time, you can pin a version in your package.json.