What You’re Doing
You’ve seen Module Federation’s runtime composition in Exercise 1 — two dev servers, remote entry manifests, shared dependency negotiation, cross-boundary state management. Now take the same analytics module and consume it as a regular workspace package. No remote entry, no shared dependency negotiation — just an npm import in a monorepo.
Why It Matters
Build-time composition is the default choice for teams that don’t need independent deployments. You get compile-time type checking across package boundaries, instant hot reload, and zero runtime overhead. Most teams should start here and only move to runtime composition when they have a concrete operational reason (like independent deploy cadences across teams).
Prerequisites
- Node.js 20+
- pnpm 9+
Setup
pnpm install
pnpm devThis exercise starts from the mainbranch. If you’re beginning the workshop fresh, you’re already in the right place. If you need to reset, run git checkout main.
Open http://localhost:5173.
Step 1: Explore the Workspace Structure
Start by understanding how the monorepo is organized.
What to Look At
- Open
pnpm-workspace.yaml— it declares four workspace groups:
packages:
- 'apps/*'
- 'packages/*'
- 'mocks'
- 'codemods'- Open
packages/analytics/package.json— find the entry point:
"main": "./src/index.ts",
"types": "./src/index.ts"Source imports, not built artifacts.The main field points directly at TypeScript source. Vite resolves workspace packages via pnpm’s symlinks and processes them with its own TypeScript transform — no separate build step needed. This is the standard monorepo DX pattern: packages are consumed as source during development. You only need dist/ output for publishing to a registry or for production builds.
- Open
packages/analytics/src/index.ts— note the explicit public API:
export { AnalyticsDashboard } from './analytics-dashboard';Only AnalyticsDashboard is exported. Internal components like StatsBar, Chart, and BigTable are not part of the public API.
- Open
apps/dashboard/src/routes/analytics.tsx— the route imports from the package:
import { AnalyticsDashboard } from '@pulse/analytics';This is a regular import — resolved at build time through the workspace, not fetched at runtime from a remote server.
Checkpoint
You should see the dashboard running at http://localhost:5173 with:
- A sidebar showing “Pulse” as the app name and “Grace Hopper” with “admin” below it
- The analytics view with stat cards (12,847 / 3,291 / $284,100 / 3.2%)
- A bar chart with time range toggles (7d / 30d / 90d)
- A “Recent Activity” table below the chart
- A green “Viewing as: Grace Hopper” badge in the header
Step 2: Compare with Runtime Composition
Think about the differences between this setup and the federation approach from Exercise 1:
| Concern | Runtime (Federation) | Build-Time (Workspace) |
|---|---|---|
| Dev servers | 2 (host + remote) | 1 |
| Type checking | Separate per remote | Across all packages |
| Hot reload | Within boundary only | Across boundaries |
| Deploy independence | Yes | No (monolith deploy) |
| Shared dependency negotiation | Runtime (singleton, eager) | None needed |
| Cross-boundary state | Nanostores (framework-agnostic) | React Context (standard) |
| Entry point complexity | Async bootstrap + manifest | Regular import |
The workspace:* protocol is pnpm’s way of declaring local dependencies.When you see "@pulse/analytics": "workspace:*" in a package.json, it tells pnpm to resolve that dependency to the matching package inside the monorepo workspace rather than fetching it from the npm registry. The * means “accept whatever version the local package declares.” pnpm creates a symlink from node_modules/@pulse/analytics to the actual packages/analytics/ directory, which is why Vite can resolve imports to TypeScript source files without a build step. When you publish packages to a registry, pnpm automatically replaces workspace:* with the real version number at publish time — so this protocol is purely a development-time convenience that disappears in production artifacts.
What to Try
Open
apps/dashboard/src/shell/auth-provider.tsx— the auth provider uses standard React Context. Compare this to Exercise 1 where you needed nanostores to cross the federation boundary.Open
packages/analytics/src/analytics-dashboard.tsx— it importsuseAuthfrom@pulse/shareddirectly. No nanostores, no store subscription — justuseContextunder the hood.
Why React Context works here:In build-time composition, all packages are bundled into a single React application. There is exactly one React instance, one component tree, and one context registry. When the AuthProvider in apps/dashboard provides a value, the useAuth() hook in packages/analytics reads from the same context — because they share the same module instance of @pulse/shared. This is the fundamental difference from federation, where separate builds create separate module instances.
Step 3: Test Cross-Package Hot Reload
One of the biggest DX wins of build-time composition is seamless hot reload across package boundaries.
- Open
packages/analytics/src/chart.tsx - Change the bar fill class from
fill-gray-800tofill-blue-600 - Save the file — the browser updates instantly without a full page reload
In the federation setup, changes to the remote required rebuilding the remote and refreshing the host. Here, Vite watches all workspace packages and processes changes through its own transform pipeline.
- Revert the change back to
fill-gray-800
Hot Module Replacement (HMR) swaps changed modules in the running application without a full page reload.When you save a file, Vite detects the change, recompiles only that module, and sends the update to the browser over a WebSocket connection. The browser replaces the old module in memory and re-renders the affected components while preserving application state — form inputs, scroll position, React component state, and navigation all survive the update. This is fundamentally different from a full page reload, which tears down the entire application, re-fetches all resources, re-parses all JavaScript, and restarts from the initial state. In a monorepo with build-time composition, HMR works across package boundaries because Vite treats all workspace packages as part of a single module graph — a change in packages/analytics/src/chart.tsx triggers an HMR update in apps/dashboard without any rebuild step.
Checkpoint
Changes to any file in packages/analytics/ are reflected in the browser instantly via hot module replacement. No rebuild, no refresh.
Step 4: Examine the Public API Boundary
The analytics package only exports AnalyticsDashboard from its index.ts. But nothing currently prevents other packages from reaching into its internals.
- Open
apps/dashboard/src/routes/analytics.tsx - Try adding a direct import of an internal component:
import { StatsBar } from '@pulse/analytics/src/stats-bar';- TypeScript resolves this just fine — the import works. But it bypasses the package’s public API. This is a common source of coupling in monorepos.
This import should not be allowed.In Exercise 6, you’ll configure eslint-plugin-boundaries to flag this as a lint error. For now, just note that the architectural boundary exists in convention (only index.ts exports are public) but is not enforced by tooling.
- Remove the import you just added
Checkpoint
You’ve confirmed that internal imports work but shouldn’t — the public API is defined by index.ts exports, but nothing enforces it yet.
Step 5: Navigate the Application
The dashboard has routing — unlike the federation setup which only had a single analytics view.
- Click “Settings” in the sidebar navigation
- The settings page loads showing org information (Pulse Inc., pro plan)
- Click “Analytics” to go back — the analytics data is still loaded
The routes are lazy-loaded using React.lazy()and Suspense. When you navigate to Settings for the first time, React dynamically imports the route module. The LoadingSkeleton component shows during the import. On subsequent navigations, the module is cached.
Checkpoint
Navigation between Analytics and Settings works. The sidebar highlights the active route. Both pages load data from MSW mock handlers.
Stretch Goals
- Add a new component to
packages/analytics: Create asummary-header.tsxcomponent, export it fromindex.ts, and use it in the dashboard. See how TypeScript integration works across the boundary. - Explore the mock API: Open
mocks/src/handlers.tsand change the delay on/api/analytics/summaryfrom200to3000. Observe how the loading state behaves when the API is slow. - Trace the dependency graph: Starting from
apps/dashboard/package.json, trace which packages depend on which. Draw the graph. This is the graph that Turborepo will optimize in Exercise 4.
Solution
If you need to catch up, the completed state for this exercise is available on the 02-streaming-start branch:
git checkout 02-streaming-start
pnpm installWhat’s Next
You’ve experienced build-time composition: one dev server, compile-time types across boundaries, instant hot reload. The analytics dashboard fetches three APIs at different speeds, but right now they all resolve together as a single loading state. In the next exercise, you’ll add streaming SSR and Suspense boundaries to progressively render each section as its data arrives.