Files
agentic-dev/docs/superpowers/plans/2026-05-04-plan-5-app-ui-integration.md
Danijel Martinek a3b72f5a7f docs(plan): add Plan 5 (app + UI integration)
17 tasks: populate core-trpc with React client + per-framework providers;
add bindProduction(config) helpers to each payload-backed feature;
swap web-next deps from @repo/api/api-client/ui to core-* + features;
wire tRPC route handler with idempotent bindAllProduction(); render
example pages (home with nav + site name + article list, /about,
/blog/[slug]); parallel-prove framework portability in web-tanstack.
First plan that produces browser-rendered output.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 08:43:04 +02:00

1059 lines
35 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Vertical Refactor — Plan 5: App + UI Integration
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans.
**Goal:** Populate `@repo/core-trpc` with the React tRPC client + per-framework providers, expose tRPC route handlers in `apps/web-next` and `apps/web-tanstack`, and render real example pages that consume the migrated features end-to-end (home with navigation, `/about` marketing page, `/blog/[slug]` article detail). This is the first plan that produces browser-rendered output. Also adds the small but critical `bindProduction*(config)` helpers each feature exports so apps can swap mock repos for Payload-backed repos at boot.
**Architecture:**
- `core-trpc` exposes a typed `trpc` React client (against `AppRouter` from `core-api`), a shared `getQueryClient`, and two provider components — one per app framework — that wire `<TRPCProvider>` + `<QueryClientProvider>`.
- Each feature with a payload-backed repository exports a `bindProduction*(config)` helper from a new `./di/bind-production.ts` file. Apps call all of them once at server boot to swap mock implementations for real Payload-backed ones. Mock implementations remain the default for tests and for any environment without Payload.
- `apps/web-next/src/app/api/trpc/[trpc]/route.ts` imports `appRouter` from `core-api` and the bind-production helpers from features. Module-scoped initialization means rebindings happen exactly once per server process. RSC server components use the same per-feature DI containers, so the bound repos work for both SSR and client-side fetches via tRPC.
- `apps/web-tanstack` does the equivalent: a route module imports `core-trpc/tanstack` provider and points at the same `appRouter` URL (proxied to `web-next`'s tRPC handler in dev — both apps can serve their own handler in production).
- Example pages: `/` (renders nav header + site name + linked blog list), `/about` (renders the `about` marketing page), `/blog/[slug]` (renders an article).
**Tech Stack:** Next.js 15.5 App Router (RSC), React 19, TanStack Start (TanStack Router 1.120), tRPC 11 React Query integration, `@trpc/tanstack-react-query`.
**Plan position:** Plan 5 of 6.
- Plans 1-4 ✅ Foundation, Blog, Auth+Media, Marketing-pages+Navigation
- **Plan 5 (this doc):** App + UI integration
- Plan 6: Cleanup + boundary enforcement + Playwright + docs rewrite
**Spec reference:** `docs/superpowers/specs/2026-04-21-vertical-monorepo-refactor-design.md`
**Lessons from earlier plans (apply throughout):**
1. vitest.config.ts has `resolve.alias` for `@/`
2. tsconfig.json has `"rootDir": "."`
3. Source files use relative imports; tests use `@/`
4. Payload-backed repos take `SanitizedConfig` via constructor
5. Apps use `transpilePackages` to allow Next.js to consume `.ts` source from workspace packages
---
## Decisions taken in this plan
- **`core-trpc` exports two providers**: `./next` for App Router (uses `'use client'`, `httpBatchLink` to `/api/trpc`) and `./tanstack` for TanStack Start (configurable URL since it points at `web-next`'s endpoint in dev). The framework-agnostic primitives (typed `trpc`, `getQueryClient`) live at the root export.
- **Production DI binding pattern: each feature exports `bindProduction*(config: SanitizedConfig)`** from `./di/bind-production.ts`. Pure function, idempotent, called once per server process. Apps build a `bindAllProduction(config)` thin wrapper that calls each. The wrapper lives in `apps/web-next/src/server/bind-production.ts` (server-only, never bundled into client code).
- **Example pages render via React Server Components (RSC)** for first paint, with one demo client component (`<LiveBlogPostUpdates>` placeholder kept simple — actual subscription wiring deferred). RSC fetches use `await caller.X()` directly; client components use `useQuery(trpc.X.queryOptions(...))`.
- **`apps/web-tanstack` deps move**: drop `@repo/api`/`@repo/api-client`/`@repo/ui`; add `@repo/core-api`/`@repo/core-trpc`/`@repo/core-ui` + the feature packages. (Same swap for `web-next`.) Old packages stay alive on disk (Plan 6 deletes them).
- **No real Payload integration test in this plan** — the smoke test is "GET /blog/[slug] returns 200 and HTML contains the seeded article title." Full e2e via Playwright lands in Plan 6.
- **Auth UI stays minimal** — no sign-in form yet; just expose `auth.signIn` etc. as tRPC procedures already done. Adding a real auth UI is a feature decision, not architecture work.
---
## File Structure
**Modify — `core-trpc/`** (currently empty):
- `packages/core-trpc/src/client.ts` — typed React tRPC client
- `packages/core-trpc/src/query-client.ts` — getQueryClient with SSR-safe singleton
- `packages/core-trpc/src/providers/next-provider.tsx``<NextTrpcProvider>`
- `packages/core-trpc/src/providers/tanstack-provider.tsx``<TanstackTrpcProvider>`
- `packages/core-trpc/src/index.ts` — barrel: `trpc`, `getQueryClient`, types
- `packages/core-trpc/package.json` — add `./next`, `./tanstack` exports
- `packages/core-trpc/tsconfig.json` — already correct from Plan 1
**Create — `bind-production.ts` in each payload-backed feature:**
- `packages/blog/src/di/bind-production.ts`
- `packages/auth/src/di/bind-production.ts`
- `packages/marketing-pages/src/di/bind-production.ts`
- `packages/navigation/src/di/bind-production.ts`
- (No media — media has no use-cases yet, just schema.)
**Modify each feature's `package.json` exports** to add `./di/bind-production`:
- blog, auth, marketing-pages, navigation
**Create — `apps/web-next/src/server/bind-production.ts`** — aggregates the per-feature binds.
**Modify — `apps/web-next/`:**
- `package.json` — swap deps (`@repo/api`/`@repo/api-client`/`@repo/ui``@repo/core-*` + feature packages)
- `next.config.mjs` — update transpilePackages list
- `src/app/api/trpc/[trpc]/route.ts` — import appRouter from `@repo/core-api`, call `bindAllProduction(config)` once at module load
- `src/app/providers.tsx` — use `<NextTrpcProvider>` from `@repo/core-trpc/next`
- `src/app/page.tsx` — render homepage with nav header + site name + blog list (RSC)
- `src/app/about/page.tsx` — render about marketing page (RSC)
- `src/app/blog/[slug]/page.tsx` — render article by slug (RSC + client component example)
- `src/app/layout.tsx` — small text — title from siteSettings (optional, kept simple)
**Modify — `apps/web-tanstack/`:**
- `package.json` — swap deps
- `src/routes/__root.tsx` — use `<TanstackTrpcProvider>` (URL pointing at web-next:3000/api/trpc in dev for shared backend; future per-app handler is a follow-up)
- `src/routes/index.tsx` — render homepage with nav (proves framework-agnostic features)
- `src/routes/blog/$slug.tsx` — article by slug
**Do NOT touch in this plan:**
- `packages/api/`, `packages/api-client/`, `packages/ui/`, `packages/core/`, `packages/cms-core/`, `packages/cms-client/` — kept alive; deleted in Plan 6.
- `apps/cms/` — already wired to `core-cms` in Plan 1.
- Boundary enforcement / ESLint plugin — Plan 6.
- Playwright — Plan 6.
---
## Phase A: Populate core-trpc
### Task 5.1: core-trpc client + query-client + index barrel
- [ ] **Step 1: Create `packages/core-trpc/src/client.ts`**
```typescript
"use client";
import { createTRPCContext } from "@trpc/tanstack-react-query";
import type { AppRouter } from "@repo/core-api";
export const { TRPCProvider, useTRPC } = createTRPCContext<AppRouter>();
```
> Note: `useTRPC` returns the typed proxy. Exported alongside `TRPCProvider` so consumers wire both in their app provider.
- [ ] **Step 2: Create `packages/core-trpc/src/query-client.ts`**
```typescript
import { QueryClient } from "@tanstack/react-query";
let clientQueryClient: QueryClient | undefined;
const defaultOptions = {
queries: {
staleTime: 30 * 1000,
refetchOnWindowFocus: false,
},
};
export function getQueryClient(): QueryClient {
if (typeof window === "undefined") {
// Server: always create a new instance per request
return new QueryClient({ defaultOptions });
}
// Browser: singleton
if (!clientQueryClient) {
clientQueryClient = new QueryClient({ defaultOptions });
}
return clientQueryClient;
}
```
- [ ] **Step 3: Replace `packages/core-trpc/src/index.ts`**
```typescript
export { useTRPC, TRPCProvider } from "./client";
export { getQueryClient } from "./query-client";
export type { AppRouter } from "@repo/core-api";
```
- [ ] **Step 4: Verify compiles**
Run: `cd packages/core-trpc && pnpm typecheck`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add packages/core-trpc/src
git commit -m "feat(core-trpc): add typed React tRPC client + getQueryClient"
```
---
### Task 5.2: Per-framework providers + package.json exports
- [ ] **Step 1: Create `packages/core-trpc/src/providers/next-provider.tsx`**
```tsx
"use client";
import { useState } from "react";
import { QueryClientProvider } from "@tanstack/react-query";
import { createTRPCClient, httpBatchLink } from "@trpc/client";
import type { AppRouter } from "@repo/core-api";
import { TRPCProvider } from "../client";
import { getQueryClient } from "../query-client";
export function NextTrpcProvider({
children,
trpcUrl = "/api/trpc",
}: {
children: React.ReactNode;
trpcUrl?: string;
}) {
const [queryClient] = useState(() => getQueryClient());
const [trpcClient] = useState(() =>
createTRPCClient<AppRouter>({
links: [httpBatchLink({ url: trpcUrl })],
}),
);
return (
<TRPCProvider trpcClient={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</TRPCProvider>
);
}
```
- [ ] **Step 2: Create `packages/core-trpc/src/providers/tanstack-provider.tsx`**
```tsx
"use client";
import { useState } from "react";
import { QueryClientProvider } from "@tanstack/react-query";
import { createTRPCClient, httpBatchLink } from "@trpc/client";
import type { AppRouter } from "@repo/core-api";
import { TRPCProvider } from "../client";
import { getQueryClient } from "../query-client";
export function TanstackTrpcProvider({
children,
trpcUrl,
}: {
children: React.ReactNode;
trpcUrl: string;
}) {
const [queryClient] = useState(() => getQueryClient());
const [trpcClient] = useState(() =>
createTRPCClient<AppRouter>({
links: [httpBatchLink({ url: trpcUrl })],
}),
);
return (
<TRPCProvider trpcClient={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</TRPCProvider>
);
}
```
> Note: TanStack provider is essentially the same as Next provider — both wrap `<TRPCProvider>` + `<QueryClientProvider>`. The two-file split is to keep the public exports clean (`@repo/core-trpc/next` vs `@repo/core-trpc/tanstack`) and to allow per-framework divergence later (e.g., Next-specific RSC integration).
- [ ] **Step 3: Update `packages/core-trpc/package.json` exports**
Replace the `exports` block:
```json
"exports": {
".": "./src/index.ts",
"./next": "./src/providers/next-provider.tsx",
"./tanstack": "./src/providers/tanstack-provider.tsx"
}
```
- [ ] **Step 4: Verify compiles**
Run: `cd packages/core-trpc && pnpm typecheck`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add packages/core-trpc
git commit -m "feat(core-trpc): add Next.js + TanStack provider components"
```
---
## Phase B: Per-feature `bindProduction` helpers
### Task 5.3: blog bindProduction helper
- [ ] **Step 1: Create `packages/blog/src/di/bind-production.ts`**
```typescript
import type { SanitizedConfig } from "payload";
import { blogContainer } from "./container";
import { BLOG_SYMBOLS } from "./symbols";
import { PayloadArticlesRepository } from "../infrastructure/repositories/payload-articles.repository";
export function bindProductionBlog(config: SanitizedConfig): void {
if (blogContainer.isBound(BLOG_SYMBOLS.IArticlesRepository)) {
blogContainer.unbind(BLOG_SYMBOLS.IArticlesRepository);
}
blogContainer
.bind(BLOG_SYMBOLS.IArticlesRepository)
.toConstantValue(new PayloadArticlesRepository(config));
}
```
- [ ] **Step 2: Add `./di/bind-production` to `packages/blog/package.json` exports**
```json
"./di/bind-production": "./src/di/bind-production.ts"
```
(Add this line inside the existing `exports` block.)
- [ ] **Step 3: Verify compiles**
Run: `cd packages/blog && pnpm typecheck`
Expected: PASS.
- [ ] **Step 4: Commit**
```bash
git add packages/blog
git commit -m "feat(blog): add bindProductionBlog(config) DI helper for app boot"
```
---
### Task 5.4: auth bindProduction helper (NO production payload repo yet — keep mock)
> Note: Auth uses `MockUsersRepository` and `MockAuthenticationService` even in production for now. There's no `PayloadUsersRepository` (Payload's auth collection works differently than the domain User entity per Plan 3 decisions). Auth's bind-production is therefore a NO-OP that just confirms the existing mock bindings are in place. This file exists for symmetry with other features and to give app boot a single bind interface.
- [ ] **Step 1: Create `packages/auth/src/di/bind-production.ts`**
```typescript
import type { SanitizedConfig as _SanitizedConfig } from "payload";
// Auth currently uses Mock repositories even in production: see Plan 3
// decisions. This helper exists for API symmetry with other features and
// for forward-compatibility if a Payload-backed users repo is added later.
//
// Until then it's a no-op that intentionally accepts (and ignores) the
// SanitizedConfig argument so app-boot code can call it uniformly.
export function bindProductionAuth(_config: _SanitizedConfig): void {
// Default mock bindings from `module.ts` already loaded by container.ts;
// nothing to swap.
}
```
- [ ] **Step 2: Add export to `packages/auth/package.json`**
```json
"./di/bind-production": "./src/di/bind-production.ts"
```
- [ ] **Step 3: Verify compiles**
Run: `cd packages/auth && pnpm typecheck`
Expected: PASS.
- [ ] **Step 4: Commit**
```bash
git add packages/auth
git commit -m "feat(auth): add bindProductionAuth(config) helper (no-op pending payload-users repo)"
```
---
### Task 5.5: marketing-pages bindProduction helper
- [ ] **Step 1: Create `packages/marketing-pages/src/di/bind-production.ts`**
```typescript
import type { SanitizedConfig } from "payload";
import { marketingPagesContainer } from "./container";
import { MARKETING_PAGES_SYMBOLS } from "./symbols";
import { PayloadPagesRepository } from "../infrastructure/repositories/payload-pages.repository";
import { PayloadSiteSettingsRepository } from "../infrastructure/repositories/payload-site-settings.repository";
export function bindProductionMarketingPages(config: SanitizedConfig): void {
if (
marketingPagesContainer.isBound(MARKETING_PAGES_SYMBOLS.IPagesRepository)
) {
marketingPagesContainer.unbind(MARKETING_PAGES_SYMBOLS.IPagesRepository);
}
marketingPagesContainer
.bind(MARKETING_PAGES_SYMBOLS.IPagesRepository)
.toConstantValue(new PayloadPagesRepository(config));
if (
marketingPagesContainer.isBound(
MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository,
)
) {
marketingPagesContainer.unbind(
MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository,
);
}
marketingPagesContainer
.bind(MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository)
.toConstantValue(new PayloadSiteSettingsRepository(config));
}
```
- [ ] **Step 2: Add to `packages/marketing-pages/package.json` exports**
```json
"./di/bind-production": "./src/di/bind-production.ts"
```
- [ ] **Step 3: Verify compiles**
Run: `cd packages/marketing-pages && pnpm typecheck`
Expected: PASS.
- [ ] **Step 4: Commit**
```bash
git add packages/marketing-pages
git commit -m "feat(marketing-pages): add bindProductionMarketingPages(config) DI helper"
```
---
### Task 5.6: navigation bindProduction helper
- [ ] **Step 1: Create `packages/navigation/src/di/bind-production.ts`**
```typescript
import type { SanitizedConfig } from "payload";
import { navigationContainer } from "./container";
import { NAVIGATION_SYMBOLS } from "./symbols";
import { PayloadHeaderRepository } from "../infrastructure/repositories/payload-header.repository";
export function bindProductionNavigation(config: SanitizedConfig): void {
if (navigationContainer.isBound(NAVIGATION_SYMBOLS.IHeaderRepository)) {
navigationContainer.unbind(NAVIGATION_SYMBOLS.IHeaderRepository);
}
navigationContainer
.bind(NAVIGATION_SYMBOLS.IHeaderRepository)
.toConstantValue(new PayloadHeaderRepository(config));
}
```
- [ ] **Step 2: Add to `packages/navigation/package.json` exports**
```json
"./di/bind-production": "./src/di/bind-production.ts"
```
- [ ] **Step 3: Verify compiles**
Run: `cd packages/navigation && pnpm typecheck`
Expected: PASS.
- [ ] **Step 4: Commit**
```bash
git add packages/navigation
git commit -m "feat(navigation): add bindProductionNavigation(config) DI helper"
```
---
### Task 5.7: Run all feature tests to confirm bind-production additions don't break existing tests
- [ ] **Step 1: Run all feature tests**
Run: `pnpm test --filter @repo/blog --filter @repo/auth --filter @repo/marketing-pages --filter @repo/navigation --filter @repo/core-shared`
Expected: PASS — 96 total (blog 26, auth 24, marketing-pages 16, navigation 4, core-shared 26).
No new commit needed (all bind-production files are additive; tests didn't import them).
---
## Phase C: Wire apps/web-next
### Task 5.8: Update apps/web-next package.json + next.config.mjs
- [ ] **Step 1: Replace `apps/web-next/package.json`**
```json
{
"name": "@repo/web-next",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"build": "echo 'Next.js build requires full environment — use pnpm dev or docker'",
"dev": "next dev --port 3000",
"lint": "eslint .",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@repo/auth": "workspace:*",
"@repo/blog": "workspace:*",
"@repo/core-api": "workspace:*",
"@repo/core-cms": "workspace:*",
"@repo/core-shared": "workspace:*",
"@repo/core-trpc": "workspace:*",
"@repo/core-ui": "workspace:*",
"@repo/marketing-pages": "workspace:*",
"@repo/media": "workspace:*",
"@repo/navigation": "workspace:*",
"@tanstack/react-query": "^5.66.0",
"next": "^15.3.0",
"payload": "^3.14.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"superjson": "^2.2.1"
},
"devDependencies": {
"@repo/eslint-config": "workspace:*",
"@repo/typescript-config": "workspace:*",
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0"
}
}
```
> Note: `@repo/api`, `@repo/api-client`, `@repo/ui` removed. `@repo/core-cms` added because the route handler imports the assembled config to pass to bind-production helpers. `payload` added because the bind helpers reference `SanitizedConfig`.
- [ ] **Step 2: Replace `apps/web-next/next.config.mjs`**
```javascript
/** @type {import('next').NextConfig} */
const nextConfig = {
transpilePackages: [
"@repo/auth",
"@repo/blog",
"@repo/core-api",
"@repo/core-cms",
"@repo/core-shared",
"@repo/core-trpc",
"@repo/core-ui",
"@repo/marketing-pages",
"@repo/media",
"@repo/navigation",
],
};
export default nextConfig;
```
- [ ] **Step 3: Install + verify**
Run: `pnpm install`
Expected: completes without error; old `@repo/api` etc. still in workspace but no longer in web-next's node_modules.
- [ ] **Step 4: Commit**
```bash
git add apps/web-next/package.json apps/web-next/next.config.mjs pnpm-lock.yaml
git commit -m "build(web-next): swap deps to core-* + feature packages, transpile new workspaces"
```
---
### Task 5.9: web-next server-side bind-production aggregator
- [ ] **Step 1: Create `apps/web-next/src/server/bind-production.ts`**
```typescript
import "server-only";
import config from "@repo/core-cms";
import { bindProductionBlog } from "@repo/blog/di/bind-production";
import { bindProductionAuth } from "@repo/auth/di/bind-production";
import { bindProductionMarketingPages } from "@repo/marketing-pages/di/bind-production";
import { bindProductionNavigation } from "@repo/navigation/di/bind-production";
let bound = false;
export async function bindAllProduction(): Promise<void> {
if (bound) return;
bound = true;
const resolvedConfig = await config;
bindProductionAuth(resolvedConfig);
bindProductionBlog(resolvedConfig);
bindProductionMarketingPages(resolvedConfig);
bindProductionNavigation(resolvedConfig);
}
```
> Notes:
> - `import 'server-only'` from Next.js fails the build if a client component imports this file. That's the safety net we want — Payload config can't be bundled into the browser.
> - The `config` default export from `@repo/core-cms` is a Promise (Payload's `buildConfig` returns one when there are async resolutions). We `await` it once.
> - `bound` flag makes `bindAllProduction()` idempotent. Multiple route handlers and RSC pages can call it; only the first invocation does work.
- [ ] **Step 2: Add `server-only` to `apps/web-next/package.json` if not already present**
Add to `dependencies`:
```json
"server-only": "^0.0.1"
```
(If `pnpm install` complains the version doesn't exist, omit it — `server-only` is a Next.js built-in marker often resolved via transitive deps. Try adding it explicitly first; remove if it causes lockfile issues.)
- [ ] **Step 3: Install + verify**
Run: `pnpm install`
Expected: completes. If `server-only` install fails, drop the import line and use a comment marker instead:
```typescript
// SERVER-ONLY: this module imports Payload config and must never be bundled into the browser.
```
- [ ] **Step 4: Verify typecheck**
Run: `pnpm typecheck --filter @repo/web-next`
Expected: PASS (or report any errors — see "If anything fails" note in Report Format).
- [ ] **Step 5: Commit**
```bash
git add apps/web-next pnpm-lock.yaml
git commit -m "feat(web-next): add server bindAllProduction() aggregator with idempotent guard"
```
---
### Task 5.10: web-next tRPC route handler + providers
- [ ] **Step 1: Replace `apps/web-next/src/app/api/trpc/[trpc]/route.ts`**
```typescript
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { appRouter } from "@repo/core-api";
import { bindAllProduction } from "../../../../server/bind-production";
const handler = async (req: Request) => {
await bindAllProduction();
return fetchRequestHandler({
endpoint: "/api/trpc",
req,
router: appRouter,
createContext: () => ({}),
});
};
export { handler as GET, handler as POST };
```
> Note: `bindAllProduction()` runs on every request but is internally idempotent — only the first call does work. This pattern works whether the Next.js dev server runs as a single long-lived process (typical) or as serverless lambdas (where each cold start re-binds, which is what we want).
- [ ] **Step 2: Replace `apps/web-next/src/app/providers.tsx`**
```tsx
"use client";
import { NextTrpcProvider } from "@repo/core-trpc/next";
export function Providers({ children }: { children: React.ReactNode }) {
return <NextTrpcProvider trpcUrl="/api/trpc">{children}</NextTrpcProvider>;
}
```
- [ ] **Step 3: Verify typecheck**
Run: `pnpm typecheck --filter @repo/web-next`
Expected: PASS.
- [ ] **Step 4: Commit**
```bash
git add apps/web-next/src/app
git commit -m "feat(web-next): wire tRPC route handler against core-api + new TrpcProvider"
```
---
### Task 5.11: web-next homepage (RSC) — nav + site name + blog list
- [ ] **Step 1: Replace `apps/web-next/src/app/page.tsx`**
```tsx
import Link from "next/link";
import { appRouter } from "@repo/core-api";
import { bindAllProduction } from "../server/bind-production";
export default async function Home() {
await bindAllProduction();
const caller = appRouter.createCaller({});
const [siteSettings, header, articles] = await Promise.all([
caller.marketingPages.siteSettings(),
caller.navigation.header(),
caller.blog.listArticles({ status: "published", limit: 20 }),
]);
return (
<main>
<header>
<h1>{siteSettings.siteName}</h1>
{siteSettings.siteDescription ? (
<p>{siteSettings.siteDescription}</p>
) : null}
<nav>
<ul>
{header.items.map((item) => (
<li key={item.href}>
<Link href={item.href}>{item.label}</Link>
</li>
))}
</ul>
</nav>
</header>
<section>
<h2>Latest articles</h2>
{articles.length === 0 ? (
<p>No published articles yet.</p>
) : (
<ul>
{articles.map((a) => (
<li key={a.id}>
<Link href={`/blog/${a.slug}`}>{a.title}</Link>
</li>
))}
</ul>
)}
</section>
</main>
);
}
```
> Note: This is a React Server Component. It calls `appRouter.createCaller({})` directly (bypassing HTTP) for SSR — the server has the same DI bindings, so use-cases resolve through Payload-backed repos. Could also use HTTP via `createTRPCClient` but the direct caller is faster and skips a network round-trip.
- [ ] **Step 2: Verify typecheck**
Run: `pnpm typecheck --filter @repo/web-next`
Expected: PASS.
- [ ] **Step 3: Commit**
```bash
git add apps/web-next/src/app/page.tsx
git commit -m "feat(web-next): render homepage with siteSettings + header + article list"
```
---
### Task 5.12: web-next /about page (RSC)
- [ ] **Step 1: Create `apps/web-next/src/app/about/page.tsx`**
```tsx
import { appRouter } from "@repo/core-api";
import { bindAllProduction } from "../../server/bind-production";
export default async function AboutPage() {
await bindAllProduction();
const caller = appRouter.createCaller({});
const page = await caller.marketingPages.pageBySlug({ slug: "about" });
if (!page) {
return (
<main>
<h1>About</h1>
<p>This page hasnt been published yet.</p>
</main>
);
}
return (
<main>
<article>
<header>
<h1>{page.hero.heading}</h1>
{page.hero.subheading ? <p>{page.hero.subheading}</p> : null}
</header>
<pre style={{ whiteSpace: "pre-wrap" }}>
{JSON.stringify(page.layout, null, 2)}
</pre>
</article>
</main>
);
}
```
> Note: rich-text/blocks rendering is intentionally simplified to a JSON dump — building a proper Lexical/blocks renderer is feature work, not architecture work. Plan 6 won't add it either; that's future enhancement.
- [ ] **Step 2: Verify typecheck**
Run: `pnpm typecheck --filter @repo/web-next`
Expected: PASS.
- [ ] **Step 3: Commit**
```bash
git add apps/web-next/src/app/about
git commit -m "feat(web-next): render /about marketing page via marketingPages.pageBySlug"
```
---
### Task 5.13: web-next /blog/[slug] page with hybrid RSC + client component
- [ ] **Step 1: Create `apps/web-next/src/app/blog/[slug]/page.tsx`**
```tsx
import { notFound } from "next/navigation";
import { appRouter } from "@repo/core-api";
import { bindAllProduction } from "../../../server/bind-production";
type PageProps = {
params: Promise<{ slug: string }>;
};
export default async function BlogPostPage({ params }: PageProps) {
await bindAllProduction();
const { slug } = await params;
const caller = appRouter.createCaller({});
const article = await caller.blog.articleBySlug({ slug });
if (!article) notFound();
return (
<main>
<article>
<header>
<h1>{article.title}</h1>
{article.publishedAt ? (
<time dateTime={article.publishedAt.toISOString()}>
{article.publishedAt.toLocaleDateString()}
</time>
) : null}
</header>
<pre style={{ whiteSpace: "pre-wrap" }}>
{JSON.stringify(article.content, null, 2)}
</pre>
</article>
</main>
);
}
```
- [ ] **Step 2: Verify typecheck**
Run: `pnpm typecheck --filter @repo/web-next`
Expected: PASS.
- [ ] **Step 3: Commit**
```bash
git add apps/web-next/src/app/blog
git commit -m "feat(web-next): render /blog/[slug] article detail via blog.articleBySlug"
```
---
### Task 5.14: web-next dev server smoke test
- [ ] **Step 1: Ensure Postgres running**
Run: `docker ps | grep postgres`. If empty: `docker compose up -d postgres`.
- [ ] **Step 2: Ensure apps/web-next has an .env (or `.env.local`)**
Check: `ls apps/web-next/.env*`. If none, copy: `cp /Users/danijel/Documents/Projects/template-vertical/apps/cms/.env apps/web-next/.env` (the same DATABASE_URL works since both connect to the shared Postgres).
- [ ] **Step 3: Boot dev server in background**
```bash
pnpm dev --filter @repo/web-next &
DEV_PID=$!
sleep 20
```
- [ ] **Step 4: Smoke-test endpoints**
```bash
curl -sf -o /dev/null -w "%{http_code}\n" http://localhost:3000/
curl -sf -o /dev/null -w "%{http_code}\n" http://localhost:3000/about
```
Expected: both return 200.
For `/blog/[slug]`, we don't have a seeded article in Payload yet — the page will return 404 (notFound). That's correct behavior. Verify:
```bash
curl -sf -o /dev/null -w "%{http_code}\n" http://localhost:3000/blog/anything
```
Expected: 404 (not 500). That confirms the route renders without crashing.
- [ ] **Step 5: Optional — verify body content**
```bash
curl -sf http://localhost:3000/ | grep -o "My App"
```
Expected: prints "My App" (the seeded `siteName` from `MockSiteSettingsRepository` OR the actual Payload-stored value if one exists).
- [ ] **Step 6: Stop dev server**
```bash
kill $DEV_PID 2>/dev/null
pkill -f "next.*3000" 2>/dev/null
```
- [ ] **Step 7: No commit (smoke test only)**
---
## Phase D: Wire apps/web-tanstack (parallel proof)
### Task 5.15: Update apps/web-tanstack package.json + provider
- [ ] **Step 1: Replace `apps/web-tanstack/package.json`**
```json
{
"name": "@repo/web-tanstack",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"build": "echo 'placeholder — TanStack Start build configured in later plan'",
"dev": "echo 'placeholder'",
"lint": "eslint .",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@repo/blog": "workspace:*",
"@repo/core-api": "workspace:*",
"@repo/core-trpc": "workspace:*",
"@repo/core-ui": "workspace:*",
"@repo/marketing-pages": "workspace:*",
"@repo/navigation": "workspace:*",
"@tanstack/react-query": "^5.66.0",
"@tanstack/react-router": "^1.120.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@repo/eslint-config": "workspace:*",
"@repo/typescript-config": "workspace:*",
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0"
}
}
```
> Note: TanStack-side does NOT include `@repo/core-cms` or `payload` — it doesn't bind production repos itself. It points its tRPC client at web-next's `/api/trpc` endpoint, which has already done the binding server-side.
- [ ] **Step 2: Replace `apps/web-tanstack/src/routes/__root.tsx`**
```tsx
import { Outlet, createRootRoute } from "@tanstack/react-router";
import { TanstackTrpcProvider } from "@repo/core-trpc/tanstack";
export const Route = createRootRoute({
component: () => (
<TanstackTrpcProvider trpcUrl="http://localhost:3000/api/trpc">
<Outlet />
</TanstackTrpcProvider>
),
});
```
- [ ] **Step 3: Install + typecheck**
Run: `pnpm install` then `pnpm typecheck --filter @repo/web-tanstack`
Expected: PASS.
- [ ] **Step 4: Commit**
```bash
git add apps/web-tanstack pnpm-lock.yaml
git commit -m "build(web-tanstack): swap deps + use TanstackTrpcProvider against shared backend"
```
---
### Task 5.16: web-tanstack example route consuming features
- [ ] **Step 1: Replace `apps/web-tanstack/src/routes/index.tsx`**
```tsx
import { createFileRoute } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
import { useTRPC } from "@repo/core-trpc";
export const Route = createFileRoute("/")({
component: Home,
});
function Home() {
const trpc = useTRPC();
const siteSettings = useQuery(trpc.marketingPages.siteSettings.queryOptions());
const header = useQuery(trpc.navigation.header.queryOptions());
if (siteSettings.isPending || header.isPending) {
return <main>Loading</main>;
}
if (siteSettings.error || header.error) {
return (
<main>
Failed to load: {siteSettings.error?.message ?? header.error?.message}
</main>
);
}
return (
<main>
<header>
<h1>{siteSettings.data?.siteName} TanStack edition</h1>
<nav>
<ul>
{header.data?.items.map((item) => (
<li key={item.href}>
<a href={item.href}>{item.label}</a>
</li>
))}
</ul>
</nav>
</header>
<p>This page is rendered by TanStack Router and consumes the same feature packages as the Next.js app.</p>
</main>
);
}
```
- [ ] **Step 2: Verify typecheck**
Run: `pnpm typecheck --filter @repo/web-tanstack`
Expected: PASS.
- [ ] **Step 3: Commit**
```bash
git add apps/web-tanstack/src/routes/index.tsx
git commit -m "feat(web-tanstack): consume marketingPages.siteSettings + navigation.header via tRPC client"
```
---
## Phase E: Final verification
### Task 5.17: Repo-wide test + typecheck + smoke
- [ ] **Step 1: Run all feature tests**
Run: `pnpm test --filter @repo/blog --filter @repo/auth --filter @repo/marketing-pages --filter @repo/navigation --filter @repo/core-shared`
Expected: PASS — 96 tests total (no new tests in Plan 5 — apps don't have unit tests).
- [ ] **Step 2: Repo-wide typecheck**
Run: `pnpm typecheck`
Expected: PASS for all packages we touched (core-trpc, all 5 features, web-next, web-tanstack, cms, core-cms, etc.). Pre-existing failures remain in `@repo/api` and `@repo/ui`.
- [ ] **Step 3: web-next dev smoke test (final)**
Postgres should still be running.
```bash
pnpm dev --filter @repo/web-next &
DEV_PID=$!
sleep 20
curl -sf -o /dev/null -w "GET / -> %{http_code}\n" http://localhost:3000/
curl -sf -o /dev/null -w "GET /about -> %{http_code}\n" http://localhost:3000/about
curl -sf -o /dev/null -w "GET /blog/anything -> %{http_code}\n" http://localhost:3000/blog/anything
kill $DEV_PID 2>/dev/null
pkill -f "next.*3000" 2>/dev/null
```
Expected: `/` and `/about` return 200; `/blog/anything` returns 404.
- [ ] **Step 4: No commit (final verification)**
---
## Plan 5 Done Criteria
- [ ] All 96 feature tests still pass
- [ ] `core-trpc` has `useTRPC`, `getQueryClient`, `NextTrpcProvider`, `TanstackTrpcProvider`
- [ ] All 4 payload-backed features export `bindProduction*(config)`
- [ ] `apps/web-next` no longer depends on `@repo/api`/`@repo/api-client`/`@repo/ui`; uses `core-*` + features instead
- [ ] `apps/web-next` dev server boots; `/` returns 200 with rendered nav + site name + blog list; `/about` returns 200; `/blog/[slug]` returns 200 (with article) or 404 (without)
- [ ] `apps/web-tanstack` typechecks against new deps; renders `/` (running TanStack dev separately is deferred to Plan 6 since TanStack Start integration is more involved — for now we just typecheck)
**Next plan:** Plan 6 — Cleanup + boundary enforcement + Playwright + docs rewrite. Deletes the old `@repo/core`, `@repo/api`, `@repo/api-client`, `@repo/cms-core`, `@repo/cms-client`, `@repo/ui` packages. Adds `eslint-plugin-boundaries` configuration enforcing the three-tag boundary model. Sets up Playwright in both apps with initial smoke specs. Rewrites root + per-package AGENTS.md, adds new ADRs, updates the adding-a-feature guide.