Proof-of-concept that three SSR apps (two SvelteKit + one Astro) can run as multi-zones in an NX monorepo, sharing a single first-party session cookie across all three. Stand-in for the planned vertical split of a monolith, with Akamai routing replaced by a local Node reverse proxy.
Proves:
@sveltejs/adapter-node for the SvelteKit apps, @astrojs/node for shop.auth.session-token, Lucia-style) crosses all three apps automatically — same hostname, no Domain= attribute needed.shared-ui package (svelte-package → dist/ with raw .svelte source + generated .d.ts + side-effect CSS). Same artifact is consumed by both the SvelteKit zones (vite-plugin-svelte) and the Astro zone (@astrojs/svelte) — no per-consumer build differences.Does not prove: production performance, real OKTA integration, K8s vs Amplify deployment shape
Requires Node ≥ 22 (uses built-in node:sqlite) and pnpm ≥ 10. Tested on Node 24 + pnpm 10.32 on Windows.
pnpm install
pnpm dev # vite dev servers + proxy on :8080
# or
pnpm build && pnpm start # production-shape Node SSR servers + proxy
Open http://localhost:8080. Click Log in. Use any username on the mock-IdP form. After redirect, you're logged in across all three zones.
localhost:8080
│
┌──────┴──────┐
│ proxy.js │ (Akamai stand-in)
└──┬──┬──┬────┘
/auth/*, /mock-idp/* ──┘ │ └── * (default)
/shop/* ──┘
│ │ │
┌──────────────────┘ │ └─────────────────┐
▼ ▼ ▼
┌────────────┐ ┌──────────────┐ ┌────────────┐
│ identity │ │ shop │ │ public │
│ port 3001 │ │ port 3002 │ │ port 3003 │
│ SvelteKit │ │ Astro │ │ SvelteKit │
│ adapter- │ │ @astrojs/ │ │ adapter- │
│ node │ │ node │ │ node │
└─────┬──────┘ └──────┬───────┘ └──────┬─────┘
│ │ │
└───────────────────────────┼─────────────────────────┘
▼
┌────────────────────────────────┐
│ packages/shared-auth │ (Lucia + node:sqlite)
│ packages/shared-ui │ (svelte-package built dist/)
└───────────────┬────────────────┘
▼
data/sessions.sqlite
(DynamoDB stand-in)
The browser sees a single origin (localhost:8080). The cookie set by identity is automatically attached to requests for any path, regardless of which upstream the proxy dispatches the request to. All three apps share the same Lucia config and read/write the same SQLite session store.
Each piece of this POC corresponds to a production component:
| POC piece | Production piece |
|---|---|
proxy.js (Node http-proxy) |
Akamai property rules, path-prefix routing |
localhost:8080 single hostname |
production single hostname |
node:sqlite shared file |
DynamoDB session table (existing dynamodb-adapter.ts in real repo) |
Mock IdP at /mock-idp/* |
Real OKTA via OAuthIdentityProvider (lives in apps/identity post-split) |
@sveltejs/adapter-node (identity, public); @astrojs/node (shop) |
amplify-adapter for Amplify-deployed countries; .k8s/server.js for K8s tier |
packages/shared-auth/ |
packages/shared/auth/ post-split |
packages/shared-ui/ (svelte-package built dist/) |
packages/rendering-machine/ (built shared-layout package with ~12+ transitive deps) |
Cookie auth.session-token |
Cookie auth.session-token — same name, same attributes |
concurrently for local boot |
Three K8s deployments / Amplify apps |
nx affected -t build |
nx affected -t build_ci in production CI |
The POC and production share identical Lucia config, identical cookie semantics, identical session-validation logic. Differences live at the boundaries (IdP, store, adapter, edge), not in the cross-zone session architecture being tested.
http://localhost:8080/shop/orders while logged out.middleware.ts calls astroSessionMiddleware from shared-auth → no cookie → Astro.locals.user = null.apps/shop/src/pages/shop/orders.astro inline guard → Astro.redirect('/auth/login?return_to=/shop/orders')./auth/login → 302 to /mock-idp/authorize?return_to=/auth/callback&state=..../auth/callback?code=...&state=..../auth/callback exchanges the code, calls findOrCreateUser + lucia.createSession. Sets auth.session-token cookie via lucia.createSessionCookie(...) with path: '/', HttpOnly, SameSite=Lax.astroSessionMiddleware validates the session via SQLite lookup → user resolved. Page SSRs with user info./account-area (public zone): same cookie attached, same session validated by public's sessionHook. No re-auth, no OKTA call, no inter-app HTTP.Open Chrome DevTools while logged in.
http://localhost:8080You should see exactly one row:
| Name | Value | Domain | Path | Expires | HttpOnly | Secure | SameSite |
|---|---|---|---|---|---|---|---|
auth.session-token |
<random> |
localhost |
/ |
session+1h | ✓ | (false in dev) | Lax |
What to confirm:
Domain= attribute in the original Set-Cookie (look at the response header in the Network tab). Browser defaulted it to localhost. This is the whole reason the cookie crosses zones — it's scoped to the hostname, and all three zones share that hostname.Path=/ — covers every URL on the host, regardless of which zone serves it.scripts/verify-auth.mjs automates the full cross-zone check. Run it after pnpm dev or pnpm start:
node scripts/verify-auth.mjs
Expected output:
✓ unauthenticated identity zone reports authenticated=false
✓ /auth/login returns 302
✓ redirects to /mock-idp/authorize
✓ final response after redirects = 200 (landed on /auth/me)
✓ auth.session-token cookie set
✓ identity zone sees session
✓ identity zone resolves user.username = alice
✓ shop zone sees the same session
✓ shop zone resolves the same user
✓ public zone sees the same session
✓ public zone resolves the same user
✓ auth.session-token cookie cleared
✓ identity zone no longer sees session
All checks passed.
Cache hit:
pnpm exec nx run shop:build
pnpm exec nx run shop:build # second run hits cache
# → "Nx read the output from the cache instead of running the command for 1 out of 1 tasks."
Affected detection (per-vertical change):
echo "// touch" >> apps/shop/src/components/ShopHomeView.tsx
pnpm exec nx affected -t build --base=HEAD
# → only `shop:build` runs
git checkout -- apps/shop/src/components/ShopHomeView.tsx
Affected detection (cross-cutting change):
echo "// touch" >> packages/shared-auth/src/lucia.ts
pnpm exec nx affected -t build --base=HEAD
# → all 3 apps build (identity, shop, public)
git checkout -- packages/shared-auth/src/lucia.ts
sveltekit-multizone-poc/
├── apps/
│ ├── identity/ # SvelteKit SSR, port 3001 — owns mock IdP, /auth/login, /auth/callback, /auth/logout, /auth/me
│ ├── shop/ # Astro SSR, port 3002 — protected /shop/orders, /shop/api/me; React + Svelte islands
│ └── public/ # SvelteKit SSR, port 3003 — anonymous-with-personalization, /api/me
├── packages/
│ ├── shared-auth/ # Lucia + node:sqlite adapter + sessionHook (SvelteKit) + astroSessionMiddleware (Astro)
│ └── shared-ui/ # svelte-package built shared layout — Header/Footer + types + loader.
│ # dist/ ships raw .svelte + generated .d.ts + side-effect CSS.
│ # peer svelte via catalog; consumed identically by SvelteKit and Astro zones.
├── scripts/
│ └── verify-auth.mjs # automated cross-zone verification
├── data/
│ └── sessions.sqlite # gitignored — shared session store
├── proxy.js # local edge router on port 8080
├── nx.json # NX task pipeline + named inputs + cache config
├── package.json # root workspace, dev/build/start scripts
└── pnpm-workspace.yaml # apps/*, packages/*, plus catalog: pinning shared devDeps
# (svelte, typescript, @sveltejs/*, vite, astro)
ORIGIN=http://localhost:8080 — set on the SvelteKit apps (identity, public) in production mode (pnpm start) so SvelteKit's CSRF check accepts cross-zone form posts. Production equivalent: set per app deployment to the public origin.HOST=127.0.0.1 — set on shop in production mode so Astro's Node adapter binds the loopback interface (the proxy is the only thing that should reach it).PORT — set per app by the start script (3001/3002/3003).SHARED_AUTH_DB — optional override for the SQLite path. Defaults to <cwd>/data/sessions.sqlite. In production, this would be DynamoDB credentials instead.