Files
agentic-dev/docs/guides/building-feature-ui.md
danijel-lf 6a5d602b3b
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
docs: add building-feature-ui guide and update agent instructions
- 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
2026-05-26 15:59:22 +02:00

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-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 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:

// 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). 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:

// 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 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:

// 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 }]. 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:

// 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/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.

// 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