Renderer Split Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Split the public rendering layer into a dedicated Next.js app in this repo, with renderer-owned API routes for raw/domain access and renderer-owned data access (no shared library yet).

Architecture: Add apps/renderer as a standalone Next.js app (own deployment). Copy the current renderer routes and required server/lib/components into the renderer app, and add minimal glue for auth/likes. Leave the existing app intact for now, optionally removing or redirecting renderer routes after the new app is deployed.

Tech Stack: Next.js 14 App Router, tRPC (server + client), NextAuth, Prisma, AWS SDK for R2 (S3), Tailwind.

Pre-checks (before Task 1)

  • Confirm adding pnpm-workspace.yaml won’t break existing scripts or tooling for the root app.
  • Decide whether to reuse the root Jest config for renderer tests or add a renderer-local Jest config.

Task 1: Add renderer app workspace scaffold

Files:

  • Create: pnpm-workspace.yaml
  • Create: apps/renderer/package.json
  • Create: apps/renderer/next.config.js
  • Create: apps/renderer/tsconfig.json
  • Create: apps/renderer/postcss.config.js
  • Create: apps/renderer/tailwind.config.js
  • Create: apps/renderer/app/layout.tsx
  • Create: apps/renderer/app/page.tsx
  • Create: apps/renderer/styles/globals.css
  • Create: apps/renderer/styles/prism.css
  • Copy: styles/fonts.ts -> apps/renderer/styles/fonts.ts

Step 1: Write the failing test

Create apps/renderer/lib/__tests__/renderer-config.test.ts:

import { resolveRendererBaseUrl } from "../renderer-config";

const baseEnv = {
  NEXT_PUBLIC_ROOT_DOMAIN: "example.com",
  NEXT_PUBLIC_RENDERER_DOMAIN: "render.example.com",
};

test("uses renderer domain when provided", () => {
  expect(resolveRendererBaseUrl(baseEnv)).toBe("https://render.example.com");
});

test("falls back to root domain when renderer domain is unset", () => {
  expect(resolveRendererBaseUrl({ NEXT_PUBLIC_ROOT_DOMAIN: "example.com" })).toBe(
    "https://example.com",
  );
});

Step 2: Run test to verify it fails

Run: pnpm test -- apps/renderer/lib/__tests__/renderer-config.test.ts Expected: FAIL with "Cannot find module '../renderer-config'".

Step 3: Write minimal implementation

Create apps/renderer/lib/renderer-config.ts:

export const resolveRendererBaseUrl = (
  env: { NEXT_PUBLIC_ROOT_DOMAIN?: string; NEXT_PUBLIC_RENDERER_DOMAIN?: string },
) => {
  const domain = env.NEXT_PUBLIC_RENDERER_DOMAIN || env.NEXT_PUBLIC_ROOT_DOMAIN;
  if (!domain) return "";
  return `https://${domain}`;
};

Step 4: Run test to verify it passes

Run: pnpm test -- apps/renderer/lib/__tests__/renderer-config.test.ts Expected: PASS.

Step 5: Commit

git add pnpm-workspace.yaml apps/renderer/package.json apps/renderer/next.config.js apps/renderer/tsconfig.json apps/renderer/postcss.config.js apps/renderer/tailwind.config.js apps/renderer/app/layout.tsx apps/renderer/app/page.tsx apps/renderer/styles/globals.css apps/renderer/styles/prism.css apps/renderer/styles/fonts.ts apps/renderer/lib/renderer-config.ts apps/renderer/lib/__tests__/renderer-config.test.ts
git commit -m "chore(renderer): scaffold renderer app"

Task 2: Copy renderer routes into the renderer app

Files:

  • Copy: app/[user]/error.tsx -> apps/renderer/app/[user]/error.tsx
  • Copy: app/[user]/[project]/[[...slug]]/page.tsx -> apps/renderer/app/[user]/[project]/[[...slug]]/page.tsx
  • Copy: app/[user]/[project]/[[...slug]]/layout.tsx -> apps/renderer/app/[user]/[project]/[[...slug]]/layout.tsx
  • Copy: app/[user]/[project]/[[...slug]]/url-normalizer.tsx -> apps/renderer/app/[user]/[project]/[[...slug]]/url-normalizer.tsx

Step 1: Write the failing test

Create apps/renderer/lib/__tests__/url-normalizer.test.ts:

import { normalizeSlug } from "../url-normalizer";

test("decodes %20 and preserves slashes", () => {
  expect(normalizeSlug(["docs%20page", "index"])).toBe("docs+page/index");
});

Step 2: Run test to verify it fails

Run: pnpm test -- apps/renderer/lib/__tests__/url-normalizer.test.ts Expected: FAIL with "Cannot find module '../url-normalizer'".

Step 3: Write minimal implementation

Create apps/renderer/lib/url-normalizer.ts:

export const normalizeSlug = (slug?: string[]) => {
  const joined = slug && slug.length > 0 ? slug.join("/") : "/";
  return joined.replace(/%20/g, "+");
};

Step 4: Run test to verify it passes

Run: pnpm test -- apps/renderer/lib/__tests__/url-normalizer.test.ts Expected: PASS.

Step 5: Commit

git add apps/renderer/app/[user]/error.tsx apps/renderer/app/[user]/[project]/[[...slug]]/page.tsx apps/renderer/app/[user]/[project]/[[...slug]]/layout.tsx apps/renderer/app/[user]/[project]/[[...slug]]/url-normalizer.tsx apps/renderer/lib/url-normalizer.ts apps/renderer/lib/__tests__/url-normalizer.test.ts
git commit -m "feat(renderer): add public render routes"

Task 3: Add renderer-owned raw/domain API routes

Files:

  • Copy: app/api/raw/[username]/[projectName]/[branch]/[[...path]]/route.tsx -> apps/renderer/app/api/raw/[username]/[projectName]/[branch]/[[...path]]/route.tsx
  • Copy: app/api/domain/[domain]/[[...slug]]/route.tsx -> apps/renderer/app/api/domain/[domain]/[[...slug]]/route.tsx
  • Create: apps/renderer/lib/r2-url.ts
  • Create: apps/renderer/lib/__tests__/r2-url.test.ts

Step 1: Write the failing test

Create apps/renderer/lib/__tests__/r2-url.test.ts:

import { buildR2RawUrl } from "../r2-url";

test("builds R2 raw file URL", () => {
  expect(
    buildR2RawUrl({
      bucketDomain: "r2.example.com",
      projectId: "site-1",
      branch: "main",
      path: "docs/readme.md",
      protocol: "https",
    }),
  ).toBe("https://r2.example.com/site-1/main/raw/docs/readme.md");
});

Step 2: Run test to verify it fails

Run: pnpm test -- apps/renderer/lib/__tests__/r2-url.test.ts Expected: FAIL with "Cannot find module '../r2-url'".

Step 3: Write minimal implementation

Create apps/renderer/lib/r2-url.ts:

export const buildR2RawUrl = ({
  bucketDomain,
  projectId,
  branch,
  path,
  protocol,
}: {
  bucketDomain: string;
  projectId: string;
  branch: string;
  path: string;
  protocol: "http" | "https";
}) => {
  const sanitizedPath = path.replace(/^\/+/, "");
  return `${protocol}://${bucketDomain}/${projectId}/${branch}/raw/${sanitizedPath}`;
};

Step 4: Run test to verify it passes

Run: pnpm test -- apps/renderer/lib/__tests__/r2-url.test.ts Expected: PASS.

Step 5: Commit

git add apps/renderer/app/api/raw/[username]/[projectName]/[branch]/[[...path]]/route.tsx apps/renderer/app/api/domain/[domain]/[[...slug]]/route.tsx apps/renderer/lib/r2-url.ts apps/renderer/lib/__tests__/r2-url.test.ts
git commit -m "feat(renderer): add raw/domain API routes"

Task 4: Copy renderer dependencies (content/render core)

Files:

  • Copy: components/MDX.tsx -> apps/renderer/components/MDX.tsx
  • Copy: components/MDX-layout.tsx -> apps/renderer/components/MDX-layout.tsx
  • Copy: components/mdx-components-factory.tsx -> apps/renderer/components/mdx-components-factory.tsx
  • Copy: components/layouts/*.tsx -> apps/renderer/components/layouts/*.tsx
  • Copy: components/comments.tsx -> apps/renderer/components/comments.tsx
  • Copy: components/edit-page-button.tsx -> apps/renderer/components/edit-page-button.tsx
  • Copy: components/error-message.tsx -> apps/renderer/components/error-message.tsx
  • Copy: components/client-components-wrapper.tsx -> apps/renderer/components/client-components-wrapper.tsx
  • Copy: lib/markdown.ts -> apps/renderer/lib/markdown.ts
  • Copy: lib/resolve-link.ts -> apps/renderer/lib/resolve-link.ts
  • Copy: lib/resolve-site-alias.ts -> apps/renderer/lib/resolve-site-alias.ts
  • Copy: lib/app-config.ts -> apps/renderer/lib/app-config.ts
  • Copy: lib/domains.ts -> apps/renderer/lib/domains.ts
  • Copy: styles/prism.css -> apps/renderer/styles/prism.css
  • Copy: styles/globals.css -> apps/renderer/styles/globals.css

Step 1: Write the failing test

Create apps/renderer/lib/__tests__/resolve-link.test.ts:

import { resolveLink } from "../resolve-link";

test("resolves a relative link against a file path", () => {
  expect(
    resolveLink({
      link: "../img.png",
      filePath: "docs/guide/index.md",
      prefixPath: "/@user/site/_r/-",
    }),
  ).toBe("/@user/site/_r/-/docs/img.png");
});

Step 2: Run test to verify it fails

Run: pnpm test -- apps/renderer/lib/__tests__/resolve-link.test.ts Expected: FAIL with "Cannot find module '../resolve-link'".

Step 3: Write minimal implementation

Copy lib/resolve-link.ts into apps/renderer/lib/resolve-link.ts and adjust imports to be local to apps/renderer.

Step 4: Run test to verify it passes

Run: pnpm test -- apps/renderer/lib/__tests__/resolve-link.test.ts Expected: PASS.

Step 5: Commit

git add apps/renderer/components apps/renderer/lib apps/renderer/styles
git commit -m "feat(renderer): copy render core"

Task 5: Copy renderer dependencies (data access + auth + likes)

Files:

  • Copy: components/like-row.tsx -> apps/renderer/components/like-row.tsx
  • Copy: components/auth/login-panel.tsx -> apps/renderer/components/auth/login-panel.tsx
  • Copy: components/modal/* -> apps/renderer/components/modal/*
  • Copy: lib/content-store.ts -> apps/renderer/lib/content-store.ts
  • Copy: lib/feature-flags.ts -> apps/renderer/lib/feature-flags.ts
  • Copy: server/api/types.ts -> apps/renderer/server/api/types.ts
  • Copy: server/api/root.ts -> apps/renderer/server/api/root.ts
  • Copy: server/api/trpc.ts -> apps/renderer/server/api/trpc.ts
  • Copy: server/api/routers/site.ts -> apps/renderer/server/api/routers/site.ts
  • Copy: server/api/routers/like.ts -> apps/renderer/server/api/routers/like.ts
  • Copy: server/auth.ts -> apps/renderer/server/auth.ts
  • Copy: server/db.ts -> apps/renderer/server/db.ts
  • Copy: trpc/react.tsx -> apps/renderer/trpc/react.tsx
  • Copy: trpc/server.ts -> apps/renderer/trpc/server.ts

Step 1: Write the failing test

Create apps/renderer/lib/__tests__/session-gate.test.ts:

import { needsAuthForLike } from "../session-gate";

test("likes require auth", () => {
  expect(needsAuthForLike()).toBe(true);
});

Step 2: Run test to verify it fails

Run: pnpm test -- apps/renderer/lib/__tests__/session-gate.test.ts Expected: FAIL with "Cannot find module '../session-gate'".

Step 3: Write minimal implementation

Create apps/renderer/lib/session-gate.ts:

export const needsAuthForLike = () => true;

Step 4: Run test to verify it passes

Run: pnpm test -- apps/renderer/lib/__tests__/session-gate.test.ts Expected: PASS.

Step 5: Commit

git add apps/renderer/components apps/renderer/lib apps/renderer/server apps/renderer/trpc
git commit -m "feat(renderer): add data access and auth"

Task 6: Wire renderer env + layout + tRPC

Files:

  • Create: apps/renderer/env.mjs
  • Create: apps/renderer/app/api/auth/[...nextauth]/route.ts
  • Create: apps/renderer/middleware.ts
  • Modify: apps/renderer/app/layout.tsx
  • Modify: apps/renderer/app/[user]/[project]/[[...slug]]/layout.tsx
  • Modify: apps/renderer/app/[user]/[project]/[[...slug]]/page.tsx

Step 1: Write the failing test

Create apps/renderer/lib/__tests__/render-env.test.ts:

import { resolveRendererBaseUrl } from "../renderer-config";

test("renderer base url uses renderer domain when set", () => {
  expect(
    resolveRendererBaseUrl({
      NEXT_PUBLIC_RENDERER_DOMAIN: "render.example.com",
      NEXT_PUBLIC_ROOT_DOMAIN: "example.com",
    }),
  ).toBe("https://render.example.com");
});

Step 2: Run test to verify it fails

Run: pnpm test -- apps/renderer/lib/__tests__/render-env.test.ts Expected: FAIL with "Cannot find module '../renderer-config'" or missing env wiring.

Step 3: Write minimal implementation

Wire apps/renderer/env.mjs and update layout to use it for metadata and app config (similar to root app/layout.tsx).

Step 4: Run test to verify it passes

Run: pnpm test -- apps/renderer/lib/__tests__/render-env.test.ts Expected: PASS.

Step 5: Commit

git add apps/renderer/env.mjs apps/renderer/app/api/auth apps/renderer/middleware.ts apps/renderer/app/layout.tsx apps/renderer/app/[user]/[project]/[[...slug]]/layout.tsx apps/renderer/app/[user]/[project]/[[...slug]]/page.tsx apps/renderer/lib/__tests__/render-env.test.ts
git commit -m "feat(renderer): wire env and layout"

Task 7: Optional cutover in existing app

Files:

  • Modify: app/[user]/[project]/[[...slug]]/page.tsx
  • Modify: app/[user]/[project]/[[...slug]]/layout.tsx
  • Modify: app/[user]/error.tsx
  • Modify: app/api/raw/[username]/[projectName]/[branch]/[[...path]]/route.tsx
  • Modify: app/api/domain/[domain]/[[...slug]]/route.tsx

Step 1: Write the failing test

Create e2e/renderer-redirect.spec.ts:

import { test, expect } from "@playwright/test";

test("public render routes redirect to renderer", async ({ page }) => {
  const response = await page.goto("/user/project");
  expect(response?.status()).toBe(302);
});

Step 2: Run test to verify it fails

Run: pnpm test:e2e -- e2e/renderer-redirect.spec.ts Expected: FAIL with 200 or 404 until redirect is added.

Step 3: Write minimal implementation

Add redirects in the existing app to NEXT_PUBLIC_RENDERER_DOMAIN and leave the renderer app to own these routes.

Step 4: Run test to verify it passes

Run: pnpm test:e2e -- e2e/renderer-redirect.spec.ts Expected: PASS with 302 and Location header to renderer domain.

Step 5: Commit

git add app/[user]/[project]/[[...slug]]/page.tsx app/[user]/[project]/[[...slug]]/layout.tsx app/[user]/error.tsx app/api/raw/[username]/[projectName]/[branch]/[[...path]]/route.tsx app/api/domain/[domain]/[[...slug]]/route.tsx e2e/renderer-redirect.spec.ts
git commit -m "chore: redirect public render routes to renderer"