Say a product needs a web app, a desktop app and a CLI. The obvious path is to build the Next.js app first, then find a way to stuff it into Electron, then find something for the CLI to reuse.
That path fights you, because it assumes Next.js is the application. The framing that works: the Next.js frontend is a shell, exactly peer to Electron. The CLI is a third shell. All three share the same logic packages.
The data flow
flowchart TD
W["Web shell
Next.js frontend"] --> CORE["packages/core
API client / state machines"]
D["Desktop shell
Electron"] --> CORE
C["CLI shell
Node.js"] --> CORE
CORE --> S["Next.js server
all server-side logic"]
S --> DB["Database / cloud"]
No shell depends on another. They all depend on core.
The layout
1 | my-project/ |
The line that matters is inside the feature package: core-logic separate from components.
The CLI is what forces that line
With only web and desktop, the split is optional. Both run React, so a feature package can be reused whole.
Add a CLI and it becomes mandatory. The CLI cannot import a React component — not “should not,” it will not run. So the feature package has to come apart: UI on one side, logic on the other, and the CLI takes only the second.
The split fixes a familiar problem as a side effect. Business logic written inside components can only be reused by dragging the UI along with it. Forced apart, the logic half is testable without jsdom and without rendering anything. You just call functions.
Platform differences get injected, not detected
The same feature behaving differently per platform is unavoidable. Click “download file”: on Electron that should write to disk via Node’s fs; on the web it should trigger a Blob download.
One option is to branch inside the feature:
1 | if (isElectron()) { /* ... */ } else { /* ... */ } |
Every new platform means revisiting every feature, and the feature package now depends on knowing where it runs.
The other option is an interface the shell fills in:
1 | // packages/feature-chat/src/types.ts |
- the web shell injects a Blob-based
saveFile - the Electron shell injects one that goes over IPC to
fsin the main process - the CLI shell passes
fs.writeFilemore or less directly
The feature knows the interface and nothing else. A fourth platform requires no change to any feature — write an implementation and inject it.
That is the anti-corruption layer: differences live in the shells and never leak into shared packages.
Where the symmetry breaks
“Next.js is peer to Electron” is only true of the client half.
All the server logic lives in Next.js API routes, which means apps/web wears two hats: one of three peer shells, and the server the other two shells talk to.
That is a reasonable arrangement — one place for server code beats three. But the cost is real:
apps/webis undeletable. Switching frontend frameworks means moving the API first.- CLI and desktop development needs the Next.js dev server running, even when they only touch API routes.
- Two responsibilities share one package, and the boundary is upheld by convention alone.
Full symmetry means a separate apps/server, with Next.js demoted to a pure shell. One more deployment unit, in exchange for three genuinely equal shells. Whether that trade is worth it depends on how likely the framework swap is.
Two things that bit
React sneaks into core-logic. One useState and the CLI is broken. Do not rely on discipline: lint-ban react imports under packages/*/core-logic, or build the CLI in CI so it fails early.
“Pure logic” is not “no side effects.” core holds git interaction and local file access, neither of which exists in a browser. So core needs its own split by runtime: the parts every platform has (API client, state machines) cannot share an entry point with the Node-only parts, or the web bundle breaks.
Next post: how authentication works across these shells — cookies on the web, bearer tokens in the CLI, and one API client for both.