Skip to content

Monorepos

Setting up splashdown in a multi-app workspace (JS workspaces, React Native / Expo, polyglot native + web, and docker-compose services) and how splash init handles each. See The recipe for the full schema.

How splash init handles monorepos

splash init is honest, not smart. It auto-scaffolds simple repos where every app maps unambiguously to a distinct resource name. It defers when it detects a collision (for example, two apps that both want a resource named PORT), writing a structure-only recipe ([project] + [apps.*] with empty resources = [] per app) and printing:

monorepo detected (N apps) — resources not auto-configured; see https://splashdown.dev/monorepos/

It also defers when a workspace contains an unclaimed sibling Xcode or Gradle project that its selected workspace manager did not enumerate. Native folders owned by a detected React Native, Expo, or Flutter app are already claimed by that app and do not trigger this fallback. A Compose file alone does not trigger it either, because a normal app plus infrastructure can still be scaffolded correctly.

That message points here. The patterns below are the copy-pasteable recipes to reach for when init defers. Hand-author the [resources.*] section with distinct env names per app. The scanner-produced [project] and [apps.*] sections are already correct.


Electron alongside a renderer

Electron is detected as a secondary capability. An Electron app that uses Vite, Next.js, or another renderer keeps that renderer's profile and resources. Init configures the renderer and nothing else, then points at Electron user-data isolation. Isolation is opt-in because it only works once you change the main process yourself.

In a workspace, give each Electron app its own identifier and its own name:

[resources.ELECTRON_PROFILE_ID_DESKTOP]
type = "template"
template = "splashdown-{{ truncate(hash(cwd_abs), 12) }}-desktop"

[resources.ELECTRON_PROFILE_ID_STUDIO]
type = "template"
template = "splashdown-{{ truncate(hash(cwd_abs), 12) }}-studio"

List each one in its own [apps.*] entry so the value reaches that app. Each main process reads its own name:

import { mkdirSync } from "node:fs"

const profileId = process.env.ELECTRON_PROFILE_ID_DESKTOP
if (profileId) {
  const userData = `${app.getPath("userData")}-${profileId}`
  mkdirSync(userData, { recursive: true })
  app.setPath("userData", userData)
}

Set userData before calling requestSingleInstanceLock(). The derived directory remains next to Electron's normal platform-specific profile rather than in the checkout. Each identifier takes the project's default destination unless you give it a writer of its own.

splash init prints that pointer whenever it detects an Electron app, including when it defers to a structure-only recipe. It never adds these resources for you, so hand-author them once you have assigned distinct names.


JS workspace: web + backend

A typical pnpm/yarn/npm monorepo with a Next.js front end and a Node (NestJS / Express / Hono) back end.

Why the collision. Both nextjs and node-backend profiles natively read a variable called PORT. A single env-file can hold only one value for a given name, so two apps with the same resource name is a conflict splashdown refuses to silently paper over. The fix is to give each app a distinct name and let the orchestrator place the value where each framework expects it.

[project]
workspace = "pnpm"
loader = "mise"

[apps.web]
path = "apps/web"
profile = "nextjs"
resources = ["WEB_PORT"]

[apps.api]
path = "apps/api"
profile = "node-backend"
resources = ["API_PORT"]

[resources.WEB_PORT]
type = "port"
range = [3001, 3100]

[resources.API_PORT]
type = "port"
range = [9000, 9100]

Root orchestrator wiring. next dev reads PORT. nest start also reads PORT. Neither reads WEB_PORT or API_PORT out of the box, so translate in the root dev script:

// package.json (root)
{
  "scripts": {
    "dev": "turbo run dev",
    "dev:web": "PORT=$WEB_PORT next dev",
    "dev:api": "PORT=$API_PORT nest start --watch"
  }
}

Or in a Turborepo turbo.json pipeline, pass them as task-level env overrides, or prepend the translation in each app's own dev script.

splashdown.env (loaded by mise/direnv/devbox) exports WEB_PORT and API_PORT. Each app's orchestrator script re-exports the right one as PORT just before the framework process starts.

Keep CORS origins on the allocated web port

If the backend validates CORS_ORIGINS, a static http://localhost:3000 or http://localhost:5173 fails as soon as splashdown allocates another port. Include the actual browser origin, or declare CORS_ORIGINS as a template that references the web resource, for example http://localhost:{{ WEB_PORT }}. A dev proxy hides this during normal /api/ calls, but direct browser requests to the backend still enforce CORS.


RN/Expo + web + backend

A full-stack mobile monorepo: a React Native (or Expo) app alongside a Next.js web client and a Node API.

[project]
workspace = "pnpm"
loader = "mise"

[apps.mobile]
path = "apps/mobile"
profile = "react-native"
resources = ["RCT_METRO_PORT"]

[apps.web]
path = "apps/web"
profile = "nextjs"
resources = ["WEB_PORT"]

[apps.api]
path = "apps/api"
profile = "node-backend"
resources = ["API_PORT"]

[resources.RCT_METRO_PORT]
type = "port"
range = [8082, 8200]

[resources.WEB_PORT]
type = "port"
range = [3001, 3100]

[resources.API_PORT]
type = "port"
range = [9000, 9100]

[targets.simulator.default]
model = "iPhone 17"

[targets.emulator.default]
device = "pixel_9"

RCT_METRO_PORT is read natively by the Metro bundler (React Native and Expo both honour it). splash doctor --fix patches metro.config.js, the package.json start script, and ios/.xcode.env so all three build paths pick up the per-checkout port. See framework-wiring.md for the full wiring details.

Run the mobile app with:

splash run simulator      # iOS sim — per-checkout instance, never collides with other worktrees
splash run emulator       # Android AVD

When exactly one declared app has a known runnable profile, the built-in launcher selects it and runs from that app's directory. This recipe declares several known apps, so launch selection is ambiguous. It also needs yarn/pnpm rather than the launcher's default command. Set explicit per-platform commands (see Custom run command):

[project.run]
ios     = "yarn --cwd apps/mobile react-native run-ios --udid {device_id}"
android = "yarn --cwd apps/mobile react-native run-android --deviceId {device_id}"

This overrides the built-in launcher. Splashdown still reconciles and boots the declared [targets.*] first, then runs your command with the booted device id injected.

Web and API are launched from the root orchestrator as in the previous pattern.


Polyglot: JS workspace + native iOS + Android

A JS workspace that also contains first-party native ios/ and android/ folders. This is typical of apps that started native and later added a web layer, or cross-platform SDKs that ship both a web demo and native sample apps.

Declare the native apps explicitly under [apps.*]. Splashdown doesn't auto-claim native folders inside a JS workspace to avoid false-positives on the generated ios//android/ directories that React Native and Expo write.

[project]
workspace = "pnpm"
loader = "mise"

[project.run]
ios = "./scripts/run-ios {device_id}"
android = "./scripts/run-android {device_id}"

[apps.web]
path = "apps/web"
profile = "nextjs"
resources = ["WEB_PORT"]

[apps.api]
path = "apps/api"
profile = "node-backend"
resources = ["API_PORT"]

[apps.ios]
path = "ios"
profile = "ios-native"
resources = []

[apps.android]
path = "android"
profile = "android-native"
resources = []

[resources.WEB_PORT]
type = "port"
range = [3001, 3100]

[resources.API_PORT]
type = "port"
range = [9000, 9100]

[targets.simulator.default]
model = "iPhone 17"

[targets.emulator.default]
device = "pixel_9"

What each native app needs in addition. If you drive an ios-native app with splash run simulator, set [project.ios] scheme yourself. splash init never records one. A run without it builds the only shared Xcode scheme in the project, and stops before touching a simulator when there is none or more than one:

[project.ios]
scheme = "MyApp"
# configuration = "Debug"   # optional, default shown

android-native picks up reasonable defaults from the project structure but you can pin them:

[project.android]
# module          = "app"
# variant         = "debug"
# application_id  = "com.example.myapp"

After installation, splashdown normally reads the selected variant's application ID from AGP's build metadata. Set application_id only for a non-standard build that does not emit it.

Run with:

splash run simulator    # xcodebuild build → xcrun simctl install/launch
splash run emulator     # ./gradlew :app:installDebug → adb shell am start

Because this recipe declares multiple known app profiles, splashdown cannot choose one built-in launcher. The two wrapper commands above must build, install, and launch the native app for the supplied device id. A repository with only one declared runnable app does not need this override, even when that app lives in a subdirectory.

For a standalone native project, run plain splash init. It detects a root Xcode workspace or project, or root Gradle build files, and emits the relevant native profile and default target. Native directories inside a JS workspace are not auto-claimed, so use the manual app and project configuration above for that layout.


docker-compose services

Splashdown never rewrites compose.yaml / docker-compose.yml. It ships no YAML parser, and rewriting a format with significant whitespace by regex is not safe enough to do to your files. What it does instead is allocate the values and tell you exactly which lines to change.

When a compose file sits at the repo root, splash init adds a COMPOSE_PROJECT_NAME resource:

[resources.COMPOSE_PROJECT_NAME]
type     = "template"
template = "{{ slug(parent) }}-{{ slug(cwd) }}-{{ truncate(hash(cwd_abs), 8) }}"

That one variable does most of the work. Compose reads it from the environment and uses it to namespace containers, networks and volumes, so two worktrees of the same repo stop colliding without either compose file changing. The readable path tail is followed by a stable hash of the resolved checkout path, so unrelated clones with matching directory names stay separate too. Your loader already exports it on cd, so a plain docker compose up picks it up.

Recipes generated before this suffix was added are not rewritten. To migrate, run docker compose down while the old COMPOSE_PROJECT_NAME is still loaded, update the template, run splash sync, and start the stack again. Compose creates a new set of named resources. Remove the old volumes only when their data is no longer needed.

Host ports still need an edit, because compose bakes them into the ports: mapping. Declare a resource per published port and reference it with compose's ${VAR:-default} form:

[resources.DB_PORT]
type  = "port"
range = [5433, 5500]
services:
  db:
    image: postgres:16
    ports:
      - "${DB_PORT:-5432}:5432"

Drop container_name: while you are there. A literal container name defeats COMPOSE_PROJECT_NAME, since it pins the container to one name across every checkout.

splash doctor reports what is still hardcoded:

✗  compose-hardcoded-ports: compose.yaml hardcodes host ports 5432, 6379; container_name myapp_db

Plain splash init detects the Compose file and declares COMPOSE_PROJECT_NAME automatically. Add DB_PORT yourself when the database needs a pinned host port, as in the example above.

Splashdown does not allocate a port per service automatically. Which services deserve a pinned host port is a judgement call, and inventing resource names for every mapping in the file would produce config you then have to undo.


Gotchas

Vite's loadEnv doesn't see splashdown.env. Vite's loadEnv(mode, dir, "") reads .env.* files from dir, not the parent shell environment. Values in splashdown.env (loaded by mise/direnv/devbox) are invisible to Vite unless the config reads process.env.WEB_DEV_PORT directly. splash doctor --fix rewrites env.Xprocess.env.X automatically. See framework-wiring.md for details.

writer = "envfile=..." breaks the mise / direnv contract. Per-resource writer = "envfile=apps/web/.env" routes a value directly into an app-level .env file instead of the default destination, and the value is not also written there. This is the right escape hatch when a build tool can only read dotenv files (legacy Gradle setup, vendor tooling), but it means mise/direnv/devbox never see that value in the parent shell, because the loader follows the default destination only. Any process that needs it must read the .env file directly. Prefer patching the consumer to read process.env so all values stay in the default destination and the full suite (status, env get, templated cross-references) works as expected.

apps.* with empty resources = [] is intentional. Native apps (ios-native, android-native) allocate no port resources, they use simulator/emulator targets, not ports. An [apps.ios] entry with resources = [] is correct and expected. It tells splash run which framework to use for the build and launch, even though no env vars are managed for it.

Adding or moving an app

Edit splashdown.toml manually or with an agent to update the app path, profile, resources, and resource associations. Keep the settings you already chose for other apps, then run splash sync to validate and apply the recipe.

For a fresh detection result to compare, use a disposable copy of the project outside the working checkout, preserving its files and workspace layout but omitting Git metadata. In that copy, run splash init --overwrite --loader none when a recipe already exists, or omit --overwrite for a new recipe. Init can also modify integration files in the copy. Compare the generated recipe with the original and selectively apply the useful changes. Running init --overwrite in the real project replaces the entire recipe, including manual edits.