---
name: split-micro-app
description: 'Use for standalone micro apps: React Router SSR/SSG, Cloud overlays, Workers, CDN/R2, SEO/OG, gateway routes, previews, Docker integration and SSR bundle isolation.'
user-invocable: false
---

# Micro App: Split, SSR, SEO, Gateway

Canonical living examples, both extracted 2026-08 — read the real files, this skill records
the decisions and landmines, not copies of the code:

- **`apps/workbench`** (`/verify`, `/acceptance`) — all code in this repo, builds and deploys from OSS CI.
- **`apps/share`** (`/share/t/:id`, `/share/page/:id`) — renders Cloud-only surfaces, so it
  builds and deploys from **lobehub-cloud** CI. See §1b before touching it.
- **`apps/auth`** (`/signin`, `/signup`, …) — the SSG variant: `ssr: false` + `prerender`, so
  the worker carries no React at all (7KB). 18 locales x 4 routes of prerendered documents.
  Also renders Cloud-only surfaces (§1b). See §3b.

## Hosting

One app, two serve paths. Do not mix them.

| Surface             | Who serves it                           | Build                                                                                                                                                                                               |
| ------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Cloud               | Gateway (torii) → Worker (SSR)          | Cloud Next does **not** `build:spa:<name>`, copy `_spa-<name>`, rewrite to `/spa-<name>`, or CDN-upload that prefix                                                                                 |
| OSS / 子部署 Docker | Next in the same image, client-rendered | `build:docker` runs `build:spa:<name>`; `<NAME>_REQUIRED=1` on `generateSpaTemplates`; Dockerfile copies `apps/<name>/package.json` before `pnpm i` and `public/_spa-<name>` into the runtime image |

`generateSpaTemplates` skips a missing `dist/<name>` HTML unless `<NAME>_REQUIRED=1`. Cloud must skip. Docker must require.

Do not revive Cloud `SPA_TARGET=<name>` Vite builds or a `/spa-<name>` middleware rewrite.

The Docker chain per app, all five links or the route is dead: root `build:docker` script →
`copySpaBuildCore.ts` target entry (`dist/<name>` → `public/_spa-<name>`) → `spaHtmlPaths.ts`
resolver + `generateSpaTemplates` block → `src/app/spa-<name>/[locale]/[[...path]]/route.ts` →
middleware rewrite (`src/libs/next/<name>Routes.ts` + `define-config.ts`) plus the path in
`src/proxy.ts`'s matcher and, for public pages, in `isPublicRoute`.

**Self-hosted loses per-page SSR.** The Next shell serves the built SPA HTML with a brand OG
card and `noindex, nofollow`; per-subject title/OG only exists in the Worker build. That is the
accepted trade, not a bug to chase.

**Dev shells — a 200 that lies.** A `/spa-<name>` handler may only call
`fetchViteDevTemplate('/index.<name>.html')` if that file exists at the repo **root** (workbench
has one; it differs from `apps/<name>/index.html` only in the entry path, which must point at
`/apps/<name>/src/entry.tsx`). Miss it and Vite's HTML fallback answers **200 with the main SPA
shell** — the micro app never loads, nothing errors, and the main SPA no longer has those routes.
An app developed against its own Vite server (share: `dev:spa:share`) carries **no dev branch at
all**. `scripts/spaDevShells.test.ts` guards both directions.

## Crossing from the main SPA

Only markdown internal entity links leave the main SPA (`InternalEntityLink` → `window.location.assign` when `shouldHardNavigateToWorkbench`). `Link` and `useWorkspaceAwareNavigate` stay in-router. Electron never hard-navs (portal). Do not add a `__WORKBENCH__` Vite define — the helper keys off the path (and skips Electron).

Share needed none of this: the only producer builds an **absolute** URL for copy/open
(`${appOrigin}/share/t/${id}` in `SharePopover`), which is already a hard navigation. Before
deleting `src/routes/<x>/**`, grep for in-router links to those paths — a surviving `Link` lands
on the main SPA's 404. Then drop the routes from `desktopRouter.config.tsx` /
`mobileRouter.config.tsx` and update `desktopRouter.sync.test.tsx` + `routeScope.test.ts`.

## 1. Splitting & Artifacts

App layout (`apps/<name>/`):

```
app/            # RR framework mode: root.tsx, routes.ts, entry.server.tsx, routes/, lib/, stubs/
workers/app.ts  # worker entry: API reverse proxy + createRequestHandler
src/            # app-owned features/shell (may coexist with a legacy SPA entry)
vite.config.rr.mts   # RR pipeline; legacy vite.config.ts can coexist (RR CLI: -c vite.config.rr.mts)
wrangler.jsonc  # name, account_id, nodejs_compat, vars (API base / app home)
staticCssOptions.mjs # ONE source for static-css hrefTemplate (vite plugin + emit + dev middleware)
```

Reuse main-src code via `@/*` deep imports + Vite 8 native `resolve.tsconfigPaths: true`
(the vite-tsconfig-paths **plugin** breaks dev SSR module-runner resolution; the app's
tsconfig `include` must cover `app/`). Stack: `@react-router/dev@8` + `@cloudflare/vite-plugin`

- repo Vite 8 (rolldown) — officially compatible.

**SSR bundle weight is the whole battle.** Main-src imports drag the app universe
(store web → chat/agent/electron). Tools and cuts, in order:

| Tool / cut                    | How                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Trace who pulls a module      | `<APP>_TRACE_MODULE=store/chat/store [<APP>_TRACE_ENV=client] bun run build:rr` — prints importer chain (`SHARE_`/`WORKBENCH_`). In an overlay repo, run it there too (§1b)                                                                                                                                                                                                                                                                                                                                        |
| Client-gate heavy routes      | `.client.tsx` module + `clientOnlyRoute()` factory (hydration gate; SSR renders loading)                                                                                                                                                                                                                                                                                                                                                                                                                           |
| SSR-stub store hubs           | vite `resolveId` stubs per env (`app/stubs/`): trpc client, services/global, store/electron, store/file, store/user, i18n loader. Stubs must be **callable empty-state hooks**, not throwing proxies — render paths call them. Keep the implemented surface explicit (no catch-all `Proxy` / `as never`): `stubSurfaceGuard` in `vite.config.rr.mts` fails the build when the graph imports an export or member the stub does not implement. Add the member (empty / `reject`) or keep the importer off the graph. |
| Client-shim lambda            | Client `@/libs/trpc/client` is a cookie-only `httpLink` (`trpcClient.client.ts`). Do not ship the real `lambda.ts` — its `headers()` pulls image store → chat store + `model-bank` catalog.                                                                                                                                                                                                                                                                                                                        |
| SSR-stub shiki langs          | On the SSR env, resolve `@shikijs/langs` / `@shikijs/themes` / shiki's `langs-bundle` / `themes` / wasm to `app/stubs/shiki.ts`. Do **not** stub the `shiki` package entry — Pierre diffs and Highlighter import named APIs from it.                                                                                                                                                                                                                                                                               |
| Client shiki from CDN         | On the client env, resolve `shiki` / `shiki/*` / `@shikijs/*` to pinned `https://esm.sh/...@<installed shiki version>` as externals. Do not bundle grammars or wasm into `build/client`.                                                                                                                                                                                                                                                                                                                           |
| Slot-inject app-only features | Context seam owned by the shared feature (see `src/features/Acceptance/Viewer/Conversation/originConversation.tsx`); app provides, micro app leaves null → affordances hide                                                                                                                                                                                                                                                                                                                                        |
| Bypass barrels                | Deep-path imports (`@/features/Acceptance/Acceptance`, not the barrel) — barrels evaluate sibling exports with side effects                                                                                                                                                                                                                                                                                                                                                                                        |
| Compose capability atoms      | Shared UI is assembled per surface; the light app never imports the fat viewer. See **`compose-atoms`**. Do not add `readOnly` on the in-app page                                                                                                                                                                                                                                                                                                                                                                  |
| Decouple dual-use components  | Lift store reads to optional props (see AudioPlayer `uploadState`/`onCancelUpload`)                                                                                                                                                                                                                                                                                                                                                                                                                                |

Share's cuts took SSR from **9.84MB → 1.78MB gzip**. Two build-level snags worth knowing:
`resolve.dedupe` must include `@lobehub/ui` (the `builtin-tool-*` packages declare a loose `^5`
and resolve to an older copy whose base-ui lacks components the app renders → `MISSING_EXPORT:
Alert`), and a `*.client` module needs its own SSR-env stub so the hydration gate does not drag
the gated tree into the worker anyway.

Products: `build/client` (assets → CDN), `build/server` (worker; deploy with
`wrangler deploy --config build/server/wrangler.json`). Budget: worker gzip ≤ 10MB paid.

**CI affected-detection**: build emits `build-inputs.txt` (module-graph file list, gitignored);
the deploy workflow diffs changed files against the manifest from the **last successful run's
artifact** (carry-forward on skip), plus meta triggers (app dir, plugins/vite, lockfile,
tsconfig, glob dirs). New `import.meta.glob` patterns in shared code need a new meta trigger —
the one manual rule. See `apps/workbench/scripts/should-build.mjs` +
`.github/workflows/deploy-workbench.yml`; the overlay-hosted variant is lobehub-cloud's
`scripts/shouldBuildShare.ts` + `.github/workflows/deploy-share.yml` (§1b).

**PR-time verify is a separate workflow per repo that can change the artifact** — deploy
ownership (§1b) does not decide verify ownership. Each verify builds the worker, uploads a
non-deployed preview **version** (`wrangler versions upload --preview-alias`, dry-run when
secrets are absent), enforces the 8MB-gzip guard, and comments the preview URL behind an HTML
marker (never `--edit-last` — other workflows comment as the same bot). Workbench: one repo,
one `verify-workbench.yml`. Share: **both** repos, because either side's change flows into the
deployed worker — OSS `verify-share.yml` + cloud `verify-share.yml`.

**Previews upload to a sibling Worker, never to the production script.** Cloudflare only rolls
back to the **100 most recently uploaded versions** of a script, and preview uploads count: at
share's PR rate (10 uploads in under an hour on a busy day) every real deployment left the
window within a day, which broke the gateway admin's (鳥居番) rollback list. So every verify
passes `--name lobehub-<name>-preview`, and the production script's version list holds only
deployments. Two consequences: the preview Worker must exist before the first upload
(`wrangler versions upload` refuses a never-deployed script — bootstrap it once with
`wrangler deploy --name lobehub-<name>-preview` from any stub; the next upload replaces it),
and the preview API token must be allowed to edit the `-preview` name. Preview URLs are then
`https://<alias>-lobehub-<name>-preview.lobeobjects-tg.workers.dev`, and `VITE_CDN_BASE` must
point at that same origin. Alias namespaces must not collide on the shared preview worker: OSS
uses `pr<N>`, cloud uses `cloudpr<N>`. Manifest source
differs by what the repo can read: cloud verify borrows the deploy's `share-deploy-state`
artifact (same repo); OSS verify cannot read cloud artifacts, so it self-bootstraps from its
own last successful run's `share-build-inputs` (first run always builds).

## 1b. When the surface renders Cloud-only code

`lobehub-cloud` includes this repo as a submodule at `lobehub/` and shadows it path-by-path
through tsconfig `paths` (`@/business/*` → `./src/business/*` then `./lobehub/src/business/*`;
`@/*` → `./src/*` then `./lobehub/src/*`). Split by **ownership**, not by which repo is handy:

`apps/auth` is the cheap case: because `ssr: false` ships no render graph, the Cloud overlay
(BusinessAuthProvider → Turnstile + referral) added **one chunk and 0 extra SSR stubs** —
measured at 585.7KB gz eager vs 587.3KB for the open-source build, with byte-identical markup.
Do not assume that; `apps/share` needed 8.2MB of stubbing. Measure per app.

| Code                                                                      | Where it goes                                                                                                         |
| ------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| Routes, shell, SSR pipeline, worker, CDN deploy, SSR stubs of OSS modules | OSS `apps/<name>` — Docker serves the same app                                                                        |
| Rendering surfaces only Cloud implements                                  | Stays in Cloud. OSS keeps the `@/business/*` stub (`return null` / passthrough, real types); the app imports the seam |
| SSR stubs for Cloud-only store hubs                                       | Cloud (`apps/<name>/stubs/*`), injected into the shared config                                                        |

The RR config is a **factory** so both hosts share one pipeline:
`apps/share/vite.config.shared.mts` exports
`createShareRrConfig({ appRoot, extraSsrStubs, repoRoot, resolvePlugins, staticCss })`, and the
OSS `vite.config.rr.mts` is a thin caller reading `SHARE_TSCONFIG_PROJECT` /
`SHARE_EXTRA_SSR_STUBS`. Cloud fills both in `scripts/shareApp.ts` and drives the submodule app
through `scripts/{buildShare,devShare,deployShare,shouldBuildShare}.ts`.

**Landmine — the overlay silently doesn't apply.** Vite 8's native `resolve.tsconfigPaths`
resolves against the tsconfig _nearest each importer_, so submodule files resolve through the
submodule's own tsconfig and the host overlay is lost. The build still succeeds and ships the
open-source fallback surfaces (for share: a blank page). An overlay build must use the
`vite-tsconfig-paths` **plugin** pinned to the host tsconfig and switch the native one off
(`tsconfigPaths: !resolvePlugins`). Both settings are right in their own context — native for
the standalone build, plugin for the overlay build. Verify by grepping `build-inputs.txt` for a
Cloud-only file.

**Whoever's code must be inside the artifact owns the build.** Share deploys from
lobehub-cloud (`.github/workflows/deploy-share.yml`), not OSS. Never add a deploy workflow to a
repo that can only produce the fallback.

**OSS PRs can still verify with the real overlay.** Same-repo OSS PRs clone the overlay repo @
HEAD via `.github/actions/business-overlay` (the clone+overlay step extracted from
`desktop-build-setup`: overlay files land in `$GITHUB_WORKSPACE/..`, which works because the
repo is named `lobehub` so the checkout already sits at the submodule path), then
`cd .. && pnpm install` and run the overlay repo's own `bun run build:share` — the tsconfig /
stub knowledge stays over there. **The OSS workflow never hardcodes the private repo
name**: it comes from the Actions repository variable `OVERLAY_REPOSITORY`; the token reuses
the pre-existing `LOBEHUB_CLOUD_TOKEN` secret (deliberately not renamed — the desktop release
workflows already reference it, and a rename would mean reconfiguring the org secret). When
either is unset (fork PRs always), the workflow falls back to the
OSS-stub build + wrangler dry-run as a pure compile/size guard. Keep new public-facing CI
wording on the neutral "business overlay" vocabulary — the older desktop release workflows
still leak the internal naming and are the known remaining exception. One trap: an overlay
build's `build-inputs.txt` is overlay-root-relative (`repoRoot =
dirname(SHARE_TSCONFIG_PROJECT)`), so OSS files appear as `lobehub/src/...` — strip that
prefix before exact-matching against the OSS repo's own diff (`sed 's#^lobehub/##'` in the
verify workflow); the meta triggers already match both spellings.

**Cloud affected-detection needs submodule history**: a bump is a single `lobehub` entry in the
host diff, so the workflow runs `git -C lobehub fetch --unshallow` and compares the previous
submodule SHA (carried in the state artifact) before diffing against `build-inputs.txt`.

**Re-run the module trace in the Cloud build** — the overlay adds chains OSS never sees
(`ShareAppShell → @/store/serverConfig/Provider → … → @/store/workspace → workspaceBootstrap →
@/store/home → @/store/chat` cost 8.2MB gzip until stubbed).

**Merge order**: OSS PR → su