docs: add building-feature-ui guide and update agent instructions
Some checks failed
CI / typecheck + lint + boundaries + test + build (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Coverage snapshot / snapshot (push) Has been cancelled
Release Please / release-please (push) Has been cancelled
Sentry PII guard (R31) / pii-guard (push) Has been cancelled
CI / Playwright e2e (push) Has been cancelled
CI / Storybook smoke tests + visual regression (push) Has been cancelled
Mutation testing (nightly) / mutate (push) Has been cancelled
Some checks failed
CI / typecheck + lint + boundaries + test + build (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Coverage snapshot / snapshot (push) Has been cancelled
Release Please / release-please (push) Has been cancelled
Sentry PII guard (R31) / pii-guard (push) Has been cancelled
CI / Playwright e2e (push) Has been cancelled
CI / Storybook smoke tests + visual regression (push) Has been cancelled
Mutation testing (nightly) / mutate (push) Has been cancelled
- New guide covers server/client component pattern, hooks, DI prefetch, HydrationBoundary hydration, core-ui atomic design reuse, Tailwind wiring, seed data, and cross-feature boundaries - Update AGENTS.md with feature UI folder structure and naming rules - Update CLAUDE.md with feature UI data fetching convention
This commit is contained in:
22
AGENTS.md
22
AGENTS.md
@@ -308,13 +308,31 @@ Each feature package exposes exactly these subpath exports:
|
||||
| Subpath | What it exports | Who consumes |
|
||||
| ---------------------- | -------------------------------------------------------------------------------------------------- | ----------------------- |
|
||||
| `.` (root) | Contracts only: types, errors, schemas, `IUseCase` / `IController` aliases, router type, constants | Any consumer |
|
||||
| `./ui` | Query builders (`queryOptions`), UI components | App packages |
|
||||
| `./ui` | Hooks (`useX`), components, query builders (`queryOptions`) | App packages |
|
||||
| `./api` | tRPC router (`xRouter` + `XRouter` type) | `@repo/core-api` only |
|
||||
| `./cms` | Payload collections | `@repo/core-cms` only |
|
||||
| `./di/bind-production` | App boot side-effect — swaps mock for real Payload impl | App packages only |
|
||||
| `./di/bind-dev-seed` | App boot side-effect — swaps empty mock for populated mock | App packages, storybook |
|
||||
|
||||
Apps import schemas/types from `@repo/<feature>` (root) and React Query builders from `@repo/<feature>/ui`. Deep source paths are not accessible — the `exports` map enforces this.
|
||||
Apps import schemas/types from `@repo/<feature>` (root) and hooks/components from `@repo/<feature>/ui`. Deep source paths are not accessible — the `exports` map enforces this.
|
||||
|
||||
### Feature UI structure
|
||||
|
||||
Each feature's `src/ui/` follows this layout:
|
||||
|
||||
```
|
||||
src/ui/
|
||||
index.ts # Barrel — exports server components as public API
|
||||
query.ts # Query builder functions (framework-agnostic)
|
||||
hooks/
|
||||
use-<entity>.ts # "use client" — wraps useTRPC + useSuspenseQuery
|
||||
components/
|
||||
<entity>-list.server.tsx # Server — DI + prefetch + HydrationBoundary (public)
|
||||
<entity>-list.client.tsx # "use client" — calls hook (internal only)
|
||||
<entity>-card.tsx # Presentational (receives props)
|
||||
```
|
||||
|
||||
Server components (`.server.tsx`) are the public API — the barrel exports them under clean names (`ArticleList`, not `ArticleListServer`). Client components (`.client.tsx`) are internal — only imported by their `.server` counterpart. Server components resolve controllers from DI, prefetch data, and wrap client components in `HydrationBoundary` for SSR + instant hydration. App pages just import and render: `<ArticleList />`, `<PageContent slug="about" />`. See [`docs/guides/building-feature-ui.md`](./docs/guides/building-feature-ui.md) for the full guide.
|
||||
|
||||
### Payload-backed features use constructor injection
|
||||
|
||||
|
||||
@@ -59,6 +59,7 @@ Turborepo + pnpm monorepo organized by vertical features. Each feature (`auth`,
|
||||
- `docs/guides/coverage.md` — 4-layer coverage cookbook (L0 vitest thresholds, L1 `pnpm coverage:diff`, L2 aggregate, L3 mutation; ADR-020)
|
||||
- `docs/guides/releasing.md` — release-please workflow: how Conventional Commits become tagged versions + per-package CHANGELOGs (ADR-021)
|
||||
- `docs/architecture/template-tiers.md` — must-have vs optional packages and how to scaffold the optionals
|
||||
- `docs/guides/building-feature-ui.md` — Feature UI components, hooks, data fetching (tRPC + React Query), SSR prefetch/hydration, seed data, DI wiring
|
||||
- `docs/guides/compliance-overview.md` — hub for operator compliance obligations: GDPR, cookie consent, DSR, and pre-launch checklist
|
||||
|
||||
## Conformance system
|
||||
@@ -105,7 +106,8 @@ See `docs/guides/coverage.md` for the cookbook and ADR-020 for the full rational
|
||||
- **Schemas in the use-case file** — Every use case exports `xInputSchema` (a `z.ZodObject` with `.strict()`; `z.object({}).strict()` for void inputs) and, for non-void use cases, `xOutputSchema`. Types: `XInput = z.infer<typeof xInputSchema>` and `XOutput`. Use case body ends with `xOutputSchema.parse(result)` before returning (runtime guarantee against malformed repository data)
|
||||
- **Controllers receive `unknown` + presenter** — Controllers `safeParse(xInputSchema)` from the use-case file and throw `InputParseError` on failure. Non-void controllers define a top-level `function presenter(value: XOutput)` and return `Promise<ReturnType<typeof presenter>>` (identity is fine — `return value`); void controllers return `Promise<void>` with no presenter
|
||||
- **Feature-scoped tRPC error mapping** — Each feature has `integrations/api/procedures.ts` exporting `xProcedure = t.procedure.use(defineErrorMiddleware([[Ctor, "TRPC_CODE"], ...]))` from `@repo/core-shared/trpc/define-error-middleware`. Routers use `xProcedure.input(xInputSchema)` — schemas are imported from the use-case file, never redefined inline. `core-shared` never enumerates feature error classes
|
||||
- **Public surface split** — Feature root (`.`) exports contracts only: types, errors, schemas, IUseCase / IController aliases, router type, constants. UI artifacts (query builders, components) live behind `./ui` (`src/ui/index.ts`). Apps import queries from `@repo/<feature>/ui`, schemas/types from `@repo/<feature>`
|
||||
- **Public surface split** — Feature root (`.`) exports contracts only: types, errors, schemas, IUseCase / IController aliases, router type, constants. UI artifacts (hooks, components, query builders) live behind `./ui` (`src/ui/index.ts`). Apps import hooks/components from `@repo/<feature>/ui`, schemas/types from `@repo/<feature>`
|
||||
- **Feature UI owns its data fetching** — Each feature's `src/ui/hooks/` contains `"use client"` hooks that wrap `useTRPC` + `useSuspenseQuery`. Connected components in `src/ui/components/` call these hooks. App pages prefetch via `appRouter.createCaller({})` and hydrate via `HydrationBoundary` + `dehydrate` + `setQueryData`. See `docs/guides/building-feature-ui.md`
|
||||
- **Payload repositories via constructor** — Feature packages receive Payload config at constructor time, not as a direct dependency
|
||||
- **Three binding modes per feature** — Each feature exports two binders: `./di/bind-production` (real Payload) and `./di/bind-dev-seed` (populated mock). The app's `bindAll()` dispatcher in `apps/web-next/src/server/bind-production.ts` picks one by env: `USE_DEV_SEED="true"` → dev seed; `NODE_ENV="production"` → production; otherwise → dev seed (developer default so `pnpm dev` boots without Payload). Dev seed lives in `src/__seeds__/dev.ts` as a lazy `buildDev<Entities>()` function that uses the feature's existing factory
|
||||
- **Binders take a `ctx` arg from `core-shared/di`** — `bindProductionX(ctx: BindProductionContext)` for production binders; `bindDevSeedX(ctx: BindContext)` for dev-seed. Required fields: `tracer`, `logger`, plus `config` for production. Optional fields: `bus`, `queue`, `realtime`, `realtimeRegistry` (correspond to optional core packages — guard with `?.` or `if (bus) { ... }` when used; use-case signatures should accept the protocol type when they only need protocol methods, not the full concrete interface). Aggregator builds one ctx object and passes it to all feature binders
|
||||
|
||||
498
docs/guides/building-feature-ui.md
Normal file
498
docs/guides/building-feature-ui.md
Normal file
@@ -0,0 +1,498 @@
|
||||
# Building Feature UI — Components, Hooks & Data Fetching
|
||||
|
||||
Each feature owns its UI layer inside `src/ui/`. This guide covers how to
|
||||
create React components that fetch their own data via tRPC + React Query,
|
||||
how to wire them into Next.js and TanStack Start apps, and how the server
|
||||
prefetch + client hydration pattern works.
|
||||
|
||||
> **Prerequisites:** `@repo/core-trpc` must be scaffolded
|
||||
> (`pnpm turbo gen core-package trpc`). The tRPC providers must be wired
|
||||
> into the app's root layout (see [App wiring](#app-wiring) below).
|
||||
|
||||
---
|
||||
|
||||
## Feature `src/ui/` folder structure
|
||||
|
||||
```
|
||||
packages/<feature>/src/ui/
|
||||
index.ts # Barrel — re-exports server components as public API
|
||||
query.ts # Query builder functions (framework-agnostic)
|
||||
hooks/
|
||||
use-<entity>.ts # "use client" hooks wrapping tRPC + useSuspenseQuery
|
||||
use-<entity>-list.ts
|
||||
components/
|
||||
<entity>-card.tsx # Presentational (receives props, no hooks)
|
||||
<entity>-list.server.tsx # Server component — DI + prefetch + HydrationBoundary
|
||||
<entity>-list.client.tsx # "use client" — calls hook, owns rendering
|
||||
<entity>-detail.server.tsx
|
||||
<entity>-detail.client.tsx
|
||||
```
|
||||
|
||||
### Naming convention
|
||||
|
||||
| File suffix | Directive | Role | Exported from barrel? |
|
||||
| ------------------ | ---------------------------- | ------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------- |
|
||||
| `.server.tsx` | _(none — server by default)_ | Resolves controller from DI, prefetches data, wraps `.client` in `HydrationBoundary` | **Yes** — under the clean name (e.g. `ArticleList`) |
|
||||
| `.client.tsx` | `"use client"` | Calls hooks, renders UI | **No** — internal to the feature; only imported by its `.server` counterpart |
|
||||
| `.tsx` (no suffix) | _(none)_ | Presentational — receives data via props, no hooks | Yes, if useful standalone (e.g. `ArticleCard`) |
|
||||
|
||||
The **server component is the public face** — the barrel exports it under
|
||||
the clean component name (`ArticleList`, `ArticleDetail`, `PageContent`).
|
||||
The `.client.tsx` suffix signals "internal, not for direct consumption" —
|
||||
consumers never see it.
|
||||
|
||||
### Component roles
|
||||
|
||||
- **Server components** (`.server.tsx`) — resolve the controller from the
|
||||
feature's DI container, call it to prefetch data, seed the React Query
|
||||
cache via `setQueryData`, and wrap the client component in
|
||||
`HydrationBoundary`. This gives SSR + instant hydration.
|
||||
- **Client components** (`.client.tsx`) — `"use client"`. Call hooks from
|
||||
`hooks/` to get data via `useSuspenseQuery`. Handle rendering + interactivity.
|
||||
Never imported by app pages directly.
|
||||
- **Hooks** (`hooks/`) — own data fetching; one hook per query.
|
||||
Always `"use client"`. Import `useTRPC` from `@repo/core-trpc` and
|
||||
`useSuspenseQuery` from `@tanstack/react-query`.
|
||||
- **Presentational components** (`.tsx`, no suffix) — receive data via props.
|
||||
No `"use client"` unless they need browser APIs. Can be shared by
|
||||
multiple client components.
|
||||
|
||||
---
|
||||
|
||||
## Component composition & `@repo/core-ui` reuse
|
||||
|
||||
Feature components **must** reuse primitives from `@repo/core-ui` rather
|
||||
than hand-rolling HTML with raw Tailwind classes. `core-ui` follows
|
||||
**Atomic Design**:
|
||||
|
||||
| Tier | Location | Examples | Rule |
|
||||
| ------------- | ------------------------ | ----------------------------------- | --------------------------------------------- |
|
||||
| **Atoms** | `core-ui/src/atoms/` | `Button`, `Input`, `Label` | Smallest building blocks. No business logic. |
|
||||
| **Molecules** | `core-ui/src/molecules/` | `FormField` (Label + Input + error) | Compose atoms. Still generic. |
|
||||
| **Organisms** | `core-ui/src/organisms/` | `CookieConsentBanner` | Compose molecules/atoms. May own local state. |
|
||||
| **Templates** | `core-ui/src/templates/` | Page shells, layout grids | Structural — define slots, no data. |
|
||||
|
||||
**Import direction is strictly upward:** atoms never import molecules;
|
||||
molecules never import organisms. The ESLint rule
|
||||
`atomic-tier-import-direction` enforces this.
|
||||
|
||||
### Where feature components fit
|
||||
|
||||
Feature components are **consumers** of core-ui, not replacements.
|
||||
They sit above the atomic tiers:
|
||||
|
||||
```
|
||||
App page (imports feature component, passes route props)
|
||||
└── Feature server component (.server.tsx — DI + prefetch + HydrationBoundary)
|
||||
└── Feature client component (.client.tsx — "use client", calls hook)
|
||||
└── Feature presentational component (receives props)
|
||||
└── core-ui atoms/molecules (Button, Input, FormField, ...)
|
||||
```
|
||||
|
||||
**Guidelines:**
|
||||
|
||||
- **Always check `core-ui` first.** Before creating a `<Card>` or
|
||||
`<Badge>` in a feature, check if `core-ui` already exports it. Use
|
||||
Storybook (`pnpm dev --filter @repo/storybook`) or the barrel at
|
||||
`packages/core-ui/src/index.ts`.
|
||||
- **If a primitive is missing, add it to `core-ui`** — not to the
|
||||
feature. Scaffold via `pnpm turbo gen core-ui-component`. Feature
|
||||
packages should not contain generic UI primitives.
|
||||
- **Feature components compose, not duplicate.** A feature's
|
||||
`<ArticleCard>` should render a `core-ui` `<Card>` (when it exists)
|
||||
with feature-specific content inside — not re-implement card styling.
|
||||
- **Tailwind utility classes are fine** for layout and spacing within
|
||||
feature components (flex, grid, padding, margin). But visual
|
||||
primitives (buttons, inputs, badges, cards) come from `core-ui`.
|
||||
|
||||
### Adding `core-ui` as a dependency
|
||||
|
||||
Feature packages that use core-ui atoms need:
|
||||
|
||||
```jsonc
|
||||
// package.json
|
||||
"dependencies": {
|
||||
"@repo/core-ui": "workspace:*"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Create a hook
|
||||
|
||||
Hooks live in `src/ui/hooks/` and wrap a single tRPC query:
|
||||
|
||||
```typescript
|
||||
// packages/blog/src/ui/hooks/use-article-list.ts
|
||||
"use client";
|
||||
|
||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { useTRPC } from "@repo/core-trpc";
|
||||
import type { Article } from "../../entities/models/article";
|
||||
|
||||
export function useArticleList(options?: {
|
||||
status?: "draft" | "published";
|
||||
limit?: number;
|
||||
}) {
|
||||
const trpc = useTRPC();
|
||||
return useSuspenseQuery(
|
||||
trpc.blog.listArticles.queryOptions({
|
||||
status: options?.status ?? "published",
|
||||
limit: options?.limit ?? 20,
|
||||
}),
|
||||
) as { data: Article[] };
|
||||
}
|
||||
```
|
||||
|
||||
> **TS2742 workaround:** Feature packages set `declaration: true` (from
|
||||
> the base tsconfig). The `as { data: T }` cast avoids a non-portable
|
||||
> return type error caused by `@trpc/client` resolving to different
|
||||
> `.pnpm` paths per package.
|
||||
|
||||
### Dependencies
|
||||
|
||||
Feature packages that have hooks need these dependencies:
|
||||
|
||||
```jsonc
|
||||
// package.json
|
||||
"dependencies": {
|
||||
"@repo/core-trpc": "workspace:*",
|
||||
"@tanstack/react-query": "^5.66.0",
|
||||
"@trpc/client": "^11.17.0", // for type portability
|
||||
"react": "^19.0.0"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Create components
|
||||
|
||||
### Server component (public face)
|
||||
|
||||
The server component resolves the controller from DI, prefetches, and
|
||||
wraps the client component in `HydrationBoundary`:
|
||||
|
||||
```typescript
|
||||
// packages/blog/src/ui/components/article-list.server.tsx
|
||||
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
|
||||
import { getQueryClient } from "@repo/core-trpc";
|
||||
import { blogContainer } from "../../di/container";
|
||||
import { BLOG_SYMBOLS } from "../../di/symbols";
|
||||
import type { IGetArticlesController } from "../../interface-adapters/controllers/get-articles.controller";
|
||||
import { ArticleList as ArticleListClient } from "./article-list.client";
|
||||
|
||||
export async function ArticleList() {
|
||||
const controller = blogContainer.get<IGetArticlesController>(
|
||||
BLOG_SYMBOLS.IGetArticlesController,
|
||||
);
|
||||
const articles = await controller({ status: "published", limit: 20 });
|
||||
const queryClient = getQueryClient();
|
||||
queryClient.setQueryData(
|
||||
["blog", "listArticles", { input: { status: "published", limit: 20 } }],
|
||||
articles,
|
||||
);
|
||||
|
||||
return (
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<ArticleListClient />
|
||||
</HydrationBoundary>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Client component (internal)
|
||||
|
||||
```typescript
|
||||
// packages/blog/src/ui/components/article-list.client.tsx
|
||||
"use client";
|
||||
|
||||
import { useArticleList } from "../hooks/use-article-list";
|
||||
import { ArticleCard } from "./article-card";
|
||||
|
||||
export function ArticleList() {
|
||||
const { data: articles } = useArticleList();
|
||||
|
||||
if (articles.length === 0) {
|
||||
return <p className="text-muted-foreground">No articles yet.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
{articles.map((article) => (
|
||||
<ArticleCard key={article.id} article={article} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Presentational component (receives props)
|
||||
|
||||
```typescript
|
||||
// packages/blog/src/ui/components/article-card.tsx
|
||||
import type { Article } from "../../entities/models/article";
|
||||
|
||||
export type ArticleCardProps = { article: Article };
|
||||
|
||||
export function ArticleCard({ article }: ArticleCardProps) {
|
||||
return (
|
||||
<article className="rounded-lg border border-border bg-card p-4">
|
||||
<a href={`/blog/${article.slug}`}>
|
||||
<h3 className="text-lg font-semibold">{article.title}</h3>
|
||||
</a>
|
||||
<time className="text-sm text-muted-foreground"
|
||||
dateTime={article.createdAt.toISOString()}>
|
||||
{article.createdAt.toLocaleDateString()}
|
||||
</time>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
> **No `renderLink` props.** Client components are `"use client"` —
|
||||
> functions cannot be passed from server components. Use plain `<a>` tags
|
||||
> or import the framework's Link component directly if the feature has
|
||||
> that framework as a dependency.
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Export from the barrel
|
||||
|
||||
The barrel exports **server components** under clean names. Client
|
||||
components are internal — never re-exported:
|
||||
|
||||
```typescript
|
||||
// packages/blog/src/ui/index.ts
|
||||
export { articleBySlugQuery, listArticlesQuery } from "./query";
|
||||
export { useArticleList } from "./hooks/use-article-list";
|
||||
export { useArticleBySlug } from "./hooks/use-article-by-slug";
|
||||
export { ArticleCard, type ArticleCardProps } from "./components/article-card";
|
||||
export { ArticleList } from "./components/article-list.server";
|
||||
export { ArticleDetail } from "./components/article-detail.server";
|
||||
```
|
||||
|
||||
Apps import from `@repo/<feature>/ui` — they get the server component
|
||||
which handles prefetch + hydration internally:
|
||||
|
||||
```typescript
|
||||
import { ArticleList } from "@repo/blog/ui";
|
||||
import { SiteHeader } from "@repo/navigation/ui";
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## App wiring
|
||||
|
||||
### Next.js (`apps/web-next`)
|
||||
|
||||
#### Root layout — DI + providers
|
||||
|
||||
`bindAll()` runs once in the root layout. `NextTrpcProvider` wraps all
|
||||
pages with the tRPC client and React Query.
|
||||
|
||||
```typescript
|
||||
// apps/web-next/src/app/layout.tsx
|
||||
import { bindAll } from "../server/bind-production";
|
||||
import { Providers } from "./providers";
|
||||
|
||||
export default async function RootLayout({ children }) {
|
||||
await bindAll();
|
||||
return (
|
||||
<html lang="en">
|
||||
<body><Providers>{children}</Providers></body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// apps/web-next/src/app/providers.tsx
|
||||
"use client";
|
||||
import { NextTrpcProvider } from "@repo/core-trpc/next";
|
||||
|
||||
export function Providers({ children }) {
|
||||
return <NextTrpcProvider>{children}</NextTrpcProvider>;
|
||||
}
|
||||
```
|
||||
|
||||
#### Pages — just import and render
|
||||
|
||||
Feature server components handle prefetch + hydration internally. App
|
||||
pages are thin — they import the component and pass route-derived props
|
||||
(slug, id, etc.). No `appRouter`, no `queryClient`, no `HydrationBoundary`
|
||||
in the app layer:
|
||||
|
||||
```typescript
|
||||
// apps/web-next/src/app/page.tsx
|
||||
import { ArticleList } from "@repo/blog/ui";
|
||||
|
||||
export default function Home() {
|
||||
return <ArticleList />;
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// apps/web-next/src/app/blog/[slug]/page.tsx
|
||||
import { ArticleDetail } from "@repo/blog/ui";
|
||||
|
||||
export default async function BlogPostPage({ params }) {
|
||||
const { slug } = await params;
|
||||
return <ArticleDetail slug={slug} />;
|
||||
}
|
||||
```
|
||||
|
||||
The server component inside the feature resolves its controller from DI,
|
||||
prefetches data, seeds the query cache, and wraps the client component
|
||||
in `HydrationBoundary`. This gives:
|
||||
|
||||
- Full HTML on first paint (SSR)
|
||||
- Instant hydration (no loading flash)
|
||||
- Background refetch on the client via `/api/trpc`
|
||||
|
||||
> **Cache key format:** tRPC generates keys as
|
||||
> `[routerName, procedureName, { input }]`. The `setQueryData` key in the
|
||||
> server component must match what the client hook's `queryOptions`
|
||||
> generates, or the client will re-fetch instead of hydrating.
|
||||
|
||||
#### tRPC HTTP endpoint
|
||||
|
||||
Client-side queries hit `/api/trpc` after hydration:
|
||||
|
||||
```typescript
|
||||
// apps/web-next/src/app/api/trpc/[trpc]/route.ts
|
||||
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
|
||||
import { appRouter } from "@repo/core-api";
|
||||
|
||||
const handler = async (req: Request) =>
|
||||
fetchRequestHandler({
|
||||
endpoint: "/api/trpc",
|
||||
req,
|
||||
router: appRouter,
|
||||
createContext: () => ({}),
|
||||
});
|
||||
|
||||
export { handler as GET, handler as POST };
|
||||
```
|
||||
|
||||
### TanStack Start (`apps/web-tanstack`)
|
||||
|
||||
Same pattern but with `TanstackTrpcProvider` from `@repo/core-trpc/tanstack`
|
||||
in the root route, and TanStack Router loaders for server prefetch.
|
||||
|
||||
---
|
||||
|
||||
## Tailwind CSS in the apps
|
||||
|
||||
Both apps and Storybook need their own CSS entry point because Tailwind v4
|
||||
scans for utility classes only in files it knows about. Monorepo packages
|
||||
live outside the app directory, so `@source` directives are required.
|
||||
|
||||
```css
|
||||
/* apps/web-next/src/styles/app.css */
|
||||
@import "tailwindcss";
|
||||
@source "../../../../packages/core-ui/src";
|
||||
@source "../../../../packages/navigation/src";
|
||||
@source "../../../../packages/blog/src";
|
||||
/* ... all feature packages with UI components */
|
||||
|
||||
@import "../../../../packages/core-ui/src/styles/theme.css";
|
||||
```
|
||||
|
||||
- **Next.js** uses `@tailwindcss/postcss` via `postcss.config.mjs`
|
||||
- **Storybook** uses `@tailwindcss/vite` prepended in `viteFinal`
|
||||
- **Theme tokens** live in `packages/core-ui/src/styles/theme.css` (single
|
||||
source of truth). Both `globals.css` and app CSS files import it.
|
||||
|
||||
---
|
||||
|
||||
## Seed data & DI binding
|
||||
|
||||
### Dev seed (`USE_DEV_SEED=true` or default in development)
|
||||
|
||||
Each feature has `src/__seeds__/dev.ts` that builds realistic mock data
|
||||
using factories from `src/__factories__/`. The dev-seed binder
|
||||
(`src/di/bind-dev-seed.ts`) populates the mock repository with this data.
|
||||
|
||||
```typescript
|
||||
// packages/blog/src/__seeds__/dev.ts
|
||||
import { articleFactory } from "../__factories__/article.factory";
|
||||
|
||||
export function buildDevArticles(): Article[] {
|
||||
return [
|
||||
articleFactory.build({
|
||||
slug: "hello-world",
|
||||
title: "Hello World",
|
||||
status: "published",
|
||||
}),
|
||||
articleFactory.build({
|
||||
slug: "second-post",
|
||||
title: "Second Post",
|
||||
status: "published",
|
||||
}),
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
### Production (`USE_DEV_SEED=false` or `NODE_ENV=production`)
|
||||
|
||||
Production binders (`src/di/bind-production.ts`) replace mock repositories
|
||||
with Payload-backed implementations. They receive a `BindProductionContext`
|
||||
with `config`, `tracer`, `logger`, `queue`.
|
||||
|
||||
### Boot dispatcher
|
||||
|
||||
`apps/web-next/src/server/bind-production.ts` picks the mode:
|
||||
|
||||
| Condition | Mode |
|
||||
| --------------------- | -------------------- |
|
||||
| `USE_DEV_SEED=false` | Production (Payload) |
|
||||
| `USE_DEV_SEED=true` | Dev seed (mocks) |
|
||||
| `NODE_ENV=production` | Production |
|
||||
| Default | Dev seed |
|
||||
|
||||
Root `.env` is loaded globally via `dotenv-cli` wrapping Turbo
|
||||
(`"dev": "dotenv -- turbo run dev"` in root `package.json`).
|
||||
|
||||
### Adding a new use case to an existing feature
|
||||
|
||||
1. Add the use case to `feature.manifest.ts`
|
||||
2. Create input/output schemas in the use-case file
|
||||
3. Write the use case factory + controller
|
||||
4. Add the tRPC procedure to `integrations/api/router.ts`
|
||||
5. Wire into both `bind-production.ts` and `bind-dev-seed.ts`
|
||||
6. Add seed data to `__seeds__/dev.ts` if applicable
|
||||
7. Create a hook in `src/ui/hooks/use-<name>.ts`
|
||||
8. Create client component in `src/ui/components/<name>.client.tsx`
|
||||
9. Create server component in `src/ui/components/<name>.server.tsx`
|
||||
10. Export the server component from `src/ui/index.ts` under the clean name
|
||||
|
||||
---
|
||||
|
||||
## Cross-feature boundaries in UI
|
||||
|
||||
- Features **may** import another feature's **root barrel** (types, schemas,
|
||||
errors) but **not** its `./ui` subpath. UI composition across features
|
||||
happens in the app layer.
|
||||
- Navigation's `<SiteHeader>` receives `siteName`/`siteDescription` as
|
||||
**props** — it does not import from `@repo/marketing-pages`. The app
|
||||
page passes these scalars (the only case where the app fetches data
|
||||
that crosses feature boundaries).
|
||||
- If a page renders components from multiple features, the app page
|
||||
imports and renders them side by side — each feature component handles
|
||||
its own data fetching internally.
|
||||
|
||||
---
|
||||
|
||||
## Checklist for new feature UI
|
||||
|
||||
- [ ] Check `core-ui` for existing atoms/molecules before creating new primitives
|
||||
- [ ] Hook in `src/ui/hooks/use-<x>.ts` with `"use client"` + `useSuspenseQuery`
|
||||
- [ ] Component(s) in `src/ui/components/` composing `core-ui` primitives (atoms -> molecules -> organisms)
|
||||
- [ ] Barrel exports in `src/ui/index.ts`
|
||||
- [ ] Server component (`.server.tsx`) with DI resolve + prefetch + `HydrationBoundary`
|
||||
- [ ] Barrel exports server component under clean name (no `Server` suffix)
|
||||
- [ ] `@repo/core-trpc`, `@tanstack/react-query`, `@trpc/client`, `react` in `package.json`
|
||||
- [ ] App page just imports and renders: `<ArticleList />` or `<ArticleDetail slug={slug} />`
|
||||
- [ ] `@source` directive in app CSS for the feature package (if it has Tailwind classes)
|
||||
- [ ] Seed data in `__seeds__/dev.ts` (for dev mode)
|
||||
- [ ] Both `bind-production.ts` and `bind-dev-seed.ts` wire the new use case
|
||||
Reference in New Issue
Block a user