- 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
18 KiB
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-trpcmust be scaffolded (pnpm turbo gen core-package trpc). The tRPC providers must be wired into the app's root layout (see 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 viasetQueryData, and wrap the client component inHydrationBoundary. This gives SSR + instant hydration. - Client components (
.client.tsx) —"use client". Call hooks fromhooks/to get data viauseSuspenseQuery. Handle rendering + interactivity. Never imported by app pages directly. - Hooks (
hooks/) — own data fetching; one hook per query. Always"use client". ImportuseTRPCfrom@repo/core-trpcanduseSuspenseQueryfrom@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-uifirst. Before creating a<Card>or<Badge>in a feature, check ifcore-uialready exports it. Use Storybook (pnpm dev --filter @repo/storybook) or the barrel atpackages/core-ui/src/index.ts. - If a primitive is missing, add it to
core-ui— not to the feature. Scaffold viapnpm turbo gen core-ui-component. Feature packages should not contain generic UI primitives. - Feature components compose, not duplicate. A feature's
<ArticleCard>should render acore-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:
// package.json
"dependencies": {
"@repo/core-ui": "workspace:*"
}
Step 1: Create a hook
Hooks live in src/ui/hooks/ and wrap a single tRPC query:
// 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). Theas { data: T }cast avoids a non-portable return type error caused by@trpc/clientresolving to different.pnpmpaths per package.
Dependencies
Feature packages that have hooks need these dependencies:
// 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:
// 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)
// 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)
// 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
renderLinkprops. 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:
// 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:
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.
// 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>
);
}
// 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:
// apps/web-next/src/app/page.tsx
import { ArticleList } from "@repo/blog/ui";
export default function Home() {
return <ArticleList />;
}
// 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 }]. ThesetQueryDatakey in the server component must match what the client hook'squeryOptionsgenerates, or the client will re-fetch instead of hydrating.
tRPC HTTP endpoint
Client-side queries hit /api/trpc after hydration:
// 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.
/* 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/postcssviapostcss.config.mjs - Storybook uses
@tailwindcss/viteprepended inviteFinal - Theme tokens live in
packages/core-ui/src/styles/theme.css(single source of truth). Bothglobals.cssand 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.
// 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
- Add the use case to
feature.manifest.ts - Create input/output schemas in the use-case file
- Write the use case factory + controller
- Add the tRPC procedure to
integrations/api/router.ts - Wire into both
bind-production.tsandbind-dev-seed.ts - Add seed data to
__seeds__/dev.tsif applicable - Create a hook in
src/ui/hooks/use-<name>.ts - Create client component in
src/ui/components/<name>.client.tsx - Create server component in
src/ui/components/<name>.server.tsx - Export the server component from
src/ui/index.tsunder the clean name
Cross-feature boundaries in UI
- Features may import another feature's root barrel (types, schemas,
errors) but not its
./uisubpath. UI composition across features happens in the app layer. - Navigation's
<SiteHeader>receivessiteName/siteDescriptionas 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-uifor existing atoms/molecules before creating new primitives - Hook in
src/ui/hooks/use-<x>.tswith"use client"+useSuspenseQuery - Component(s) in
src/ui/components/composingcore-uiprimitives (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
Serversuffix) @repo/core-trpc,@tanstack/react-query,@trpc/client,reactinpackage.json- App page just imports and renders:
<ArticleList />or<ArticleDetail slug={slug} /> @sourcedirective in app CSS for the feature package (if it has Tailwind classes)- Seed data in
__seeds__/dev.ts(for dev mode) - Both
bind-production.tsandbind-dev-seed.tswire the new use case