Aligns tooling packages with the core-* naming convention used by all other foundation packages (core-shared, core-cms, core-api, core-trpc, core-ui). Updates ~50 files: package.json names, devDependencies, tsconfig extends, eslint.config imports, vitest.config imports, AGENTS.md references, and the boundaries plugin patterns to match the new paths. The tooling-specific patterns in boundaries/elements are now ordered BEFORE the broader core-* pattern to ensure correct first-match-wins behavior. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
35 KiB
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-trpcexposes a typedtrpcReact client (againstAppRouterfromcore-api), a sharedgetQueryClient, 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.tsfile. 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.tsimportsappRouterfromcore-apiand 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-tanstackdoes the equivalent: a route module importscore-trpc/tanstackprovider and points at the sameappRouterURL (proxied toweb-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 theaboutmarketing 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):
- vitest.config.ts has
resolve.aliasfor@/ - tsconfig.json has
"rootDir": "." - Source files use relative imports; tests use
@/ - Payload-backed repos take
SanitizedConfigvia constructor - Apps use
transpilePackagesto allow Next.js to consume.tssource from workspace packages
Decisions taken in this plan
core-trpcexports two providers:./nextfor App Router (uses'use client',httpBatchLinkto/api/trpc) and./tanstackfor TanStack Start (configurable URL since it points atweb-next's endpoint in dev). The framework-agnostic primitives (typedtrpc,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 abindAllProduction(config)thin wrapper that calls each. The wrapper lives inapps/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 useawait caller.X()directly; client components useuseQuery(trpc.X.queryOptions(...)). apps/web-tanstackdeps move: drop@repo/api/@repo/api-client/@repo/ui; add@repo/core-api/@repo/core-trpc/@repo/core-ui+ the feature packages. (Same swap forweb-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.signInetc. 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 clientpackages/core-trpc/src/query-client.ts— getQueryClient with SSR-safe singletonpackages/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, typespackages/core-trpc/package.json— add./next,./tanstackexportspackages/core-trpc/tsconfig.json— already correct from Plan 1
Create — bind-production.ts in each payload-backed feature:
packages/blog/src/di/bind-production.tspackages/auth/src/di/bind-production.tspackages/marketing-pages/src/di/bind-production.tspackages/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 listsrc/app/api/trpc/[trpc]/route.ts— import appRouter from@repo/core-api, callbindAllProduction(config)once at module loadsrc/app/providers.tsx— use<NextTrpcProvider>from@repo/core-trpc/nextsrc/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 depssrc/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 tocore-cmsin 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
"use client";
import { createTRPCContext } from "@trpc/tanstack-react-query";
import type { AppRouter } from "@repo/core-api";
export const { TRPCProvider, useTRPC } = createTRPCContext<AppRouter>();
Note:
useTRPCreturns the typed proxy. Exported alongsideTRPCProviderso consumers wire both in their app provider.
- Step 2: Create
packages/core-trpc/src/query-client.ts
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
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
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
"use client";
import { useState } from "react";
import { QueryClientProvider } from "@tanstack/react-query";
import { createTRPCClient, httpBatchLink } from "@trpc/client";
import superjson from "superjson";
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, transformer: superjson })],
}),
);
return (
<TRPCProvider trpcClient={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</TRPCProvider>
);
}
Note:
transformer: superjsonis required to match the server's superjson transformer incore-shared/src/trpc/init.ts. Without it, complex types likeDatewon't deserialize correctly across the wire.
- Step 2: Create
packages/core-trpc/src/providers/tanstack-provider.tsx
"use client";
import { useState } from "react";
import { QueryClientProvider } from "@tanstack/react-query";
import { createTRPCClient, httpBatchLink } from "@trpc/client";
import superjson from "superjson";
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, transformer: superjson })],
}),
);
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/nextvs@repo/core-trpc/tanstack) and to allow per-framework divergence later (e.g., Next-specific RSC integration).
- Step 3: Update
packages/core-trpc/package.jsonexports
Replace the exports block:
"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
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
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-productiontopackages/blog/package.jsonexports
"./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
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
MockUsersRepositoryandMockAuthenticationServiceeven in production for now. There's noPayloadUsersRepository(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
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
"./di/bind-production": "./src/di/bind-production.ts"
- Step 3: Verify compiles
Run: cd packages/auth && pnpm typecheck
Expected: PASS.
- Step 4: Commit
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
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.jsonexports
"./di/bind-production": "./src/di/bind-production.ts"
- Step 3: Verify compiles
Run: cd packages/marketing-pages && pnpm typecheck
Expected: PASS.
- Step 4: Commit
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
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.jsonexports
"./di/bind-production": "./src/di/bind-production.ts"
- Step 3: Verify compiles
Run: cd packages/navigation && pnpm typecheck
Expected: PASS.
- Step 4: Commit
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
{
"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/core-eslint": "workspace:*",
"@repo/core-typescript": "workspace:*",
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0"
}
}
Note:
@repo/api,@repo/api-client,@repo/uiremoved.@repo/core-cmsadded because the route handler imports the assembled config to pass to bind-production helpers.payloadadded because the bind helpers referenceSanitizedConfig.
- Step 2: Replace
apps/web-next/next.config.mjs
/** @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
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
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
configdefault export from@repo/core-cmsis a Promise (Payload'sbuildConfigreturns one when there are async resolutions). Weawaitit once.boundflag makesbindAllProduction()idempotent. Multiple route handlers and RSC pages can call it; only the first invocation does work.
- Step 2: Add
server-onlytoapps/web-next/package.jsonif not already present
Add to dependencies:
"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:
// 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
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
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
"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
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
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 viacreateTRPCClientbut 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
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
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
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
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
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
pnpm dev --filter @repo/web-next &
DEV_PID=$!
sleep 20
- Step 4: Smoke-test endpoints
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:
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
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
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
{
"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/core-eslint": "workspace:*",
"@repo/core-typescript": "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-cmsorpayload— it doesn't bind production repos itself. It points its tRPC client at web-next's/api/trpcendpoint, which has already done the binding server-side.
- Step 2: Replace
apps/web-tanstack/src/routes/__root.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
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
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
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.
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-trpchasuseTRPC,getQueryClient,NextTrpcProvider,TanstackTrpcProvider- All 4 payload-backed features export
bindProduction*(config) apps/web-nextno longer depends on@repo/api/@repo/api-client/@repo/ui; usescore-*+ features insteadapps/web-nextdev server boots;/returns 200 with rendered nav + site name + blog list;/aboutreturns 200;/blog/[slug]returns 200 (with article) or 404 (without)apps/web-tanstacktypechecks 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.