From App to Platform: Monorepo Architecture and Shared Packages


The puzzle app worked. Users were playing. But every time we thought about adding a second game, we ran into the same wall: the codebase was built for one app, not a platform.

The auth context lived deep inside the puzzle client. The typed API client was hardcoded to puzzle routes. The GlassHeader component, the FAB navigation menu, the analytics module — all woven together with puzzle-specific assumptions. Building Memory Match (our next game) would have meant copy-pasting hundreds of lines and maintaining two drifting copies of the same infrastructure.

So we stopped and restructured. One session to convert the monolith into a real platform.

The Before State

The puzzle app’s structure was sensible for a single product:

apps/puzzle/
  client/      — React frontend
  server/      — Bun backend
  shared/      — Types shared between client/server

Everything lived in one place. Auth, analytics, navigation, UI components — all under apps/puzzle/client/src/. The types were puzzle-specific. The API client knew the exact puzzle route strings.

The problem: none of it was reusable without copying.

The Monorepo Structure

After the refactor, the repository looks like this:

apps/
  puzzle/
    client/      — puzzle-specific React code
    server/      — puzzle backend (now thinner)
    shared/      — puzzle-specific API routes and types
packages/
  shared/        — zero-dependency types (IUser, ApiResponse<T>)
  ui-core/       — shared React components, hooks, contexts
services/
  auth/          — standalone auth microservice

Three layers with clear ownership:

  • packages/shared — the foundation. Pure TypeScript types, no dependencies. Any app or service can import IUser without pulling in React or Mongoose.
  • packages/ui-core — the shared UI layer. React components, the auth context, the API client, core analytics. Games consume this without knowing how it works.
  • services/ — standalone backend services. Each service is its own deployable unit.

The puzzle app’s existing import paths didn’t change. Every component still imports from the same places. The reorganization was invisible to the existing code — the puzzle app files became thin wrappers re-exporting from ui-core.

packages/shared — Zero-Dependency Types

The first extraction was the simplest: shared type contracts.

// packages/shared/index.ts
export interface ApiResponse<T> {
  success: boolean;
  data?: T;
  error?: string;
}

export interface IUser {
  _id?: string;
  email?: string;
  name?: string;
  avatar?: string;
  age?: number;
  levelOverride?: number;
  isGuest?: boolean;
}

IUser and ApiResponse<T> were previously defined in apps/puzzle/shared/types.ts. Now they live in @kids-games/shared — a package with no dependencies, importable by the puzzle app, the auth service, the future Memory Match app, anything.

The puzzle’s shared types file became a re-export:

// apps/puzzle/shared/types.ts
export type { ApiResponse, IUser } from "@kids-games/shared";
export interface IItem { /* puzzle-specific */ }

No callers changed. The underlying source moved, the imports stayed the same.

packages/ui-core — The Shared UI Layer

This was the bigger extraction. ui-core is the package that makes adding a new game fast.

What moved into ui-core:

  • GlassHeader — the glassmorphic header component
  • FAB — floating action button (made generic via FABItem[] prop)
  • Layout — base layout with Outlet support
  • api — typed HTTP client (get, post, put, del) built on the ApiResponse<T> contract
  • AuthProvider / useAuth — authentication context, configurable via authRoutes prop
  • initAnalytics, identifyUser, resetUser, trackAppSessionStart — core analytics initialization

What stayed in the puzzle app:

  • Puzzle-specific FAB items (Home, Collection, Profile, Dev Status)
  • Game event tracking (trackThemeSelected, trackPuzzleCompleted, etc.)
  • The puzzle Layout (needs runtime React context for its FAB items)

The division is deliberate. ui-core knows nothing about puzzles. It doesn’t import route strings, doesn’t hardcode navigation items, doesn’t reference theme names. It’s platform infrastructure.

Making AuthProvider Configurable

The auth context was the trickiest extraction. The original AuthProvider hardcoded the puzzle server’s API routes:

// before — puzzle-specific
fetch('/api/auth/me')
fetch('/api/auth/guest', { method: 'POST', body: ... })

The new version accepts routes as a prop:

// packages/ui-core/src/context/AuthContext.tsx
export interface AuthRoutes {
  me: string;
  guest: string;
  logout: string;
  updateProfile: string;
}

export function AuthProvider({
  children,
  authRoutes,
}: {
  children: ReactNode;
  authRoutes: AuthRoutes;
}) {
  // uses authRoutes.me, authRoutes.guest, etc.
}

The puzzle app wraps it with its own route constants:

// apps/puzzle/client/src/context/AuthContext.tsx
const PUZZLE_AUTH_ROUTES = {
  me: API_ROUTES.AUTH_ME,
  guest: API_ROUTES.AUTH_GUEST,
  logout: API_ROUTES.AUTH_LOGOUT,
  updateProfile: API_ROUTES.PROFILE_UPDATE,
};

export function AuthProvider({ children }: { children: ReactNode }) {
  return <CoreAuthProvider authRoutes={PUZZLE_AUTH_ROUTES}>{children}</CoreAuthProvider>;
}

From the rest of the puzzle app’s perspective, nothing changed — they import AuthProvider and useAuth from the same local path they always did. Under the hood, the logic is in ui-core.

Memory Match will pass its own authRoutes to the same provider. One auth implementation, multiple games.

Making FAB Generic

The original FAB was hardcoded with puzzle navigation items. The new one takes a prop:

// packages/ui-core/src/components/FAB.tsx
export interface FABItem {
  id: string;
  icon: ReactNode;
  label: string;
  onClick: () => void;
  badge?: string | number;
}

export default function FAB({ items, hideAttr }: { items: FABItem[]; hideAttr?: string }) {
  // renders items dynamically
}

The puzzle app builds its own items and passes them in:

// apps/puzzle/client/src/components/FAB.tsx
const items: FABItem[] = [
  { id: "home", icon: <HomeIcon />, label: "Home", onClick: () => navigate("/") },
  { id: "collection", icon: <CollectionIcon />, label: "Collection", onClick: ... },
  { id: "profile", icon: user?.name[0], label: "Profile", onClick: ... },
];

return <CoreFAB items={items} />;

A new game with completely different navigation — say, Memory Match with a different set of pages — just builds a different items array.

The Thin Wrapper Pattern

The key to making this migration seamless: every puzzle component that moved into ui-core left behind a thin wrapper in its original location.

// apps/puzzle/client/src/components/FAB.tsx (after migration)
import { FAB as CoreFAB } from "@kids-games/ui-core";
// ... build puzzle-specific items
return <CoreFAB items={items} />;
// apps/puzzle/client/src/lib/analytics.ts (after migration)
export { initAnalytics, identifyUser, resetUser, trackAppSessionStart } from "@kids-games/ui-core";
// puzzle-specific events stay here
export function trackPuzzleCompleted(...) { ... }

No import paths changed anywhere in the app. Components and pages kept importing from the same local paths. The wrappers just forwarded to ui-core. This meant we could migrate incrementally and verify nothing broke at each step.

Docker Build Context

One wrinkle: adding packages/ to the workspace meant Docker builds needed to know about those packages. The apps/puzzle/client/Dockerfile and apps/puzzle/server/Dockerfile both run bun install --frozen-lockfile, which requires all workspace package.json files to be present.

The fix was to copy the workspace stubs into the build stage before installing:

# apps/puzzle/client/Dockerfile
COPY package.json bun.lock tsconfig.json ./
COPY packages/ packages/
COPY apps/puzzle/shared/ apps/puzzle/shared/
COPY apps/puzzle/client/ apps/puzzle/client/
COPY apps/puzzle/server/package.json apps/puzzle/server/package.json
COPY services/auth/package.json services/auth/package.json

RUN bun install --frozen-lockfile

The key insight: Bun needs package.json for every workspace member during install to resolve the dependency graph. You don’t need the full source — just the manifests. Only the packages actually needed for the build (puzzle client’s own source, packages/shared, packages/ui-core) get their full source copied.

What Adding a New Game Looks Like Now

With this structure in place, adding Memory Match means:

  1. bun create apps/memory-match/client — new React app
  2. Import AuthProvider, useAuth, GlassHeader, FAB, api from @kids-games/ui-core
  3. Pass authRoutes pointing at whatever backend endpoints this game uses
  4. Build game-specific FAB items, pass to CoreFAB
  5. Write game events in the new app’s analytics.ts using gtagEvent from ui-core

No forking auth logic. No re-implementing the API client. No duplicating the analytics initialization. The platform handles the boilerplate; the game focuses on the game.


The refactor took one session and touched roughly 20 files. The puzzle app works exactly as before — 19 tests, all passing, same CI pipeline. The difference is what comes next. The next game isn’t starting from scratch. It’s starting from a working foundation.