Full-stack pnpm + Turborepo monorepo skill, independent of the separate backend and frontend monorepo skills it draws on. Repository shape is split four ways at the top level: applications-backend, applications-frontend, packages-backend, packages-frontend — so a path states which runtime a file ends up in. Defaults: NestJS for backend services, React with IBM Carbon Design for browser applications; the project's stated architecture overrides both. Covers workspace globs, the Turborepo pipeline, filtered validation, blast-radius checks, Module Federation, turbo prune image builds, and Helm. Documents one sanctioned cross-boundary dependency: API contract types owned by the backend, imported type-only by the frontend. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
7.7 KiB
Frontend Applications
Everything under applications-frontend/*: React application layout, IBM Carbon Design setup,
Module Federation wiring, and the nginx container image.
Read this when adding an application, a page, a component or a hook; theming or styling with Carbon; wiring a host to a remote; or debugging a runtime module-loading failure.
Default stack: React with IBM Carbon Design (@carbon/react). If the project
architecture specifies otherwise, follow the architecture — the layout, federation, and image
rules below remain framework-neutral.
1. Application workspace layout
applications-frontend/<app-name>/
├── src/
│ ├── index.tsx # entry — mounts React, imports Carbon styles
│ ├── bootstrap.tsx # async boundary, required when federated (§4)
│ ├── App.tsx
│ ├── pages/<Page>/
│ ├── components/<Component>/ # app-local components only
│ ├── hooks/
│ ├── api/ # typed clients; imports contract types type-only
│ └── styles/
│ └── _carbon.scss # single Carbon import + theme configuration
├── public/
├── Dockerfile
├── nginx.conf
├── package.json
├── tsconfig.json
└── vite.config.ts | webpack.config.cjs # webpack when Module Federation is used
A component belongs in packages-frontend/* only when a second application needs it and
its API has settled. Until then it stays app-local — a shared component that is still changing
imposes its churn on every consumer.
2. IBM Carbon Design
Install @carbon/react in the application workspace, never at the root.
Import Carbon styles once, in one SCSS entry point. Importing them per-component multiplies bundle size and produces unpredictable cascade order:
// src/styles/_carbon.scss
@use "@carbon/react" with (
$font-path: "@ibm/plex"
);
Rules that keep a Carbon application coherent:
- Use Carbon components before writing your own. A hand-rolled button or modal loses Carbon's keyboard handling, focus management, and ARIA wiring — accessibility work that is already done and easy to get wrong.
- Theme with Carbon tokens, never raw hex.
$background,$text-primary,$layer-01. A raw colour is invisible to theme switching and breaks the moment someone enables dark mode. - Use the Carbon grid (
Grid,Column) rather than ad-hoc flexbox for page structure, so breakpoints match the rest of the system. - Never mix in a second component library. Two design systems in one app means two type scales, two grids, and tokens that fight each other.
- Follow Carbon's spacing scale (
$spacing-01…$spacing-13) instead of arbitrary pixels.
Theme selection belongs at the application root via Carbon's Theme component, so a switch
propagates to every component without per-component overrides.
3. Talking to backend services
API clients live in src/api/. Import contract types type-only from the owning backend
package — a value import pulls server code into the browser bundle and nothing in the build
will flag it (SKILL.md §3):
import type { UserDto } from "@<scope>/user-contracts";
export async function fetchUser(id: string): Promise<UserDto> {
const res = await fetch(`${BASE_PATH}/users/${id}`);
if (!res.ok) throw new Error(`fetchUser failed: ${res.status}`);
return res.json() as Promise<UserDto>;
}
Base paths come from configuration, never hardcoded per call site — when a service's base path changes, one constant should move, not twenty fetch calls.
4. Module Federation
Used when applications are composed at runtime: a host loads remotes over HTTP. Both are built and deployed independently, so nothing catches a mismatch at compile time. The contract has three parts, and all three must agree:
- the remote's URL and its
remoteEntry.jsfilename, - the exact keys in the remote's
exposes, matched by the host's imports, - the shared-dependency singleton settings on both sides.
// remote — webpack.config.cjs
new ModuleFederationPlugin({
name: "remoteApp",
filename: "remoteEntry.js",
exposes: { "./Widget": "./src/components/Widget" },
shared: { react: { singleton: true, requiredVersion: deps.react },
"react-dom": { singleton: true, requiredVersion: deps["react-dom"] } },
});
// host — webpack.config.cjs
new ModuleFederationPlugin({
name: "hostApp",
remotes: { remoteApp: "remoteApp@https://<host>/remoteEntry.js" },
shared: { react: { singleton: true, requiredVersion: deps.react },
"react-dom": { singleton: true, requiredVersion: deps["react-dom"] } },
});
The async bootstrap is mandatory. src/index.tsx must do nothing but
import("./bootstrap"). Federated modules resolve asynchronously; importing React directly in
the entry file evaluates it before the shared scope is initialised, and you get two React
copies with hook errors that look nothing like a configuration problem.
Failure modes and what they actually mean:
| Symptom | Cause |
|---|---|
Shared module is not available for eager consumption |
missing async bootstrap |
Invalid hook call / two React copies |
singleton: true missing on one side |
ScriptExternalLoadError |
wrong remote URL, or the remote is not deployed |
| Host builds, blank page at runtime | exposes key renamed on the remote only |
Changing any part of the contract is a breaking change for the other side (SKILL.md §8).
5. Container image
Build with turbo prune, then serve the static output from nginx:
FROM node:22-alpine AS pruner
WORKDIR /app
RUN corepack enable
COPY . .
RUN pnpm dlx turbo prune --scope=@<scope>/<app> --docker
FROM node:22-alpine AS builder
WORKDIR /app
RUN corepack enable
COPY --from=pruner /app/out/json/ .
RUN pnpm install --frozen-lockfile
COPY --from=pruner /app/out/full/ .
RUN pnpm turbo run build --filter=@<scope>/<app>
FROM nginx:alpine AS runner
COPY --from=builder /app/applications-frontend/<app>/dist /usr/share/nginx/html
COPY applications-frontend/<app>/nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 8080
nginx.conf must route unknown paths back to index.html, or a client-side route will 404 on
refresh and deep links will not work:
location / {
try_files $uri $uri/ /index.html;
}
Anything baked in at build time is public. Build-time environment variables are compiled into the bundle — never put a secret in one.
6. Validation
pnpm check-types --filter="@<scope>/<app>"
pnpm lint --filter="@<scope>/<app>"
pnpm build --filter="@<scope>/<app>"
Federated applications additionally require host and remote running together — separate green builds prove nothing about a runtime contract:
pnpm dev --filter="@<scope>/<remote>"
pnpm dev --filter="@<scope>/<host>"
Then load the host and confirm the remote's component actually renders, with a clean console.
7. Mistakes to avoid
- Importing backend contract types with a value import instead of
import type. - Importing Carbon styles in more than one place.
- Raw hex colours or arbitrary pixel spacing instead of Carbon tokens and the spacing scale.
- Hand-rolling a component Carbon already provides.
- Mixing a second component library into a Carbon application.
- Omitting the async bootstrap in a federated application.
- Changing an
exposeskey, remote name, or remote URL on one side only. - Setting
singleton: trueon one side of a shared dependency but not the other. - Missing the
try_filesfallback innginx.conf. - Baking a secret into a build-time environment variable.
- Promoting an app-local component into
packages-frontend/*before a second consumer exists.