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>
1059 lines
35 KiB
Markdown
1059 lines
35 KiB
Markdown
# 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 hasn’t 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.
|