Connector setup-page route extraction pattern
Status: canonical as of v5.4 (2026-05-19, retrospective E)
Scope: when promoting a host-side src/app/connectors/<slug>/page.tsx into a self-contained extensions/cinatra-ai/<slug>-connector/ extension so the page is served by the unified dispatch route at /connectors/cinatra-ai/<slug>/setup.
When to use this pattern
Section titled “When to use this pattern”You’re folding an existing host route into its connector extension so the extension is genuinely self-contained (per v5.4 D4). Pre-conditions:
- The host page already lives at
src/app/connectors/<slug>/page.tsxand works. - The extension package
extensions/cinatra-ai/<slug>-connector/exists. - The dispatch route
src/app/connectors/[vendor]/[slug]/[subroute]/page.tsx(v5.4 Phase 395) is in place; the loader map atsrc/lib/connector-setup-pages.tshas a placeholder entry for the slug.
Three-file split
Section titled “Three-file split”Extract the host page into THREE files inside the extension package:
extensions/cinatra-ai/<slug>-connector/src/├── <slug>-setup-impl.tsx # the page implementation (named export)├── <slug>-setup-actions.ts # server actions extracted from the host route (if any)└── setup-page.tsx # default-exported dispatch-route adapter (thin wrapper)The split is load-bearing for three reasons:
setup-page.tsxis the contract surface. It’s a thin default-exported async server component that adapts the dispatch route’sConnectorSetupPageProps({ packageId, slug, searchParams }) to whatever the named-export<Slug>ConnectorPageImplactually wants. Keep it ≤20 lines. Nothing else lives here.<slug>-setup-impl.tsxcarries the page UI. Move the original host page verbatim, then changeexport default async function <Slug>ConnectorPage(...)→export async function <Slug>ConnectorPageImpl(...). Update internal imports from@/app/...to./...where they were colocated in the host route directory.<slug>-setup-actions.tsis the form-action home. If the host route had its ownactions.tscolocated, extract it as a sibling. Update:- Imports that resolved against
@cinatra-ai/<slug>-connector(the extension itself) →./index(the file is now INSIDE the extension). - All
revalidatePath(...)/redirect(...)strings that point at/connectors/<slug>→/connectors/cinatra-ai/<slug>-connector/setup.
- Imports that resolved against
Mechanical recipe
Section titled “Mechanical recipe”WT=/Users/ordnas/Code/cinatra/.claude/worktrees/<your-worktree>SLUG=<slug> # e.g. "tailscale"EXT=$WT/extensions/cinatra-ai/${SLUG}-connector/srcHOST=$WT/src/app/connectors/${SLUG}
# 1. Copy page + colocated files into the extension.cp $HOST/page.tsx $EXT/${SLUG}-setup-impl.tsxcp $HOST/actions.ts $EXT/${SLUG}-setup-actions.ts # if presentcp $HOST/<other-client>.tsx $EXT/<other-client>.tsx # if present
# 2. Rename default export to a named one (in <slug>-setup-impl.tsx).sed -i.bak "s|export default async function ${SLUG^}ConnectorPage|export async function ${SLUG^}ConnectorPageImpl|" $EXT/${SLUG}-setup-impl.tsx
# 3. Flip self-referential package imports inside extracted files.# `@cinatra-ai/<slug>-connector` → `./index` (we're now INSIDE that package).sed -i.bak "s|@cinatra-ai/${SLUG}-connector\"|./index\"|g" $EXT/${SLUG}-setup-actions.ts $EXT/${SLUG}-setup-impl.tsx
# 4. Flip redirect / revalidatePath strings to the canonical dispatch path.sed -i.bak "s|/connectors/${SLUG}|/connectors/cinatra-ai/${SLUG}-connector/setup|g" $EXT/${SLUG}-setup-impl.tsx $EXT/${SLUG}-setup-actions.ts
# 5. Flip action import from "./actions" → "./<slug>-setup-actions" inside any# client component that used the colocated `actions.ts`.sed -i.bak "s|from \"./actions\"|from \"./${SLUG}-setup-actions\"|" $EXT/<other-client>.tsx
# 6. Write the setup-page.tsx adapter (default export, ~15 lines):cat > $EXT/setup-page.tsx <<'EOF'import { <Slug>ConnectorPageImpl } from "./<slug>-setup-impl";type ConnectorSetupPageProps = { packageId: string; slug: string; searchParams: Record<string, string | string[] | undefined>;};export default async function <Slug>ConnectorSetupPage({ searchParams,}: ConnectorSetupPageProps) { return <Slug>ConnectorPageImpl({ searchParams: Promise.resolve(searchParams) });}EOF
# 7. Wire tsconfig path alias.# "@cinatra-ai/<slug>-connector/setup-page": ["./extensions/cinatra-ai/<slug>-connector/src/setup-page.tsx"]# (add manually in tsconfig.json paths block)
# 8. Flip the loader map in src/lib/connector-setup-pages.ts from# PLACEHOLDER_SETUP_PAGE_LOADER → () => import("@cinatra-ai/<slug>-connector/setup-page").
# 9. Delete the host route.rm -rf $HOST
# 10. Typecheck. If clean, commit.( cd $WT && corepack pnpm typecheck )Gotchas
Section titled “Gotchas”- Default vs named export. The dispatch route does
const SetupPage = mod.default. Ifsetup-page.tsxexports a named function instead of default, the dispatch route resolvesundefinedand React renders nothing. Always default-export. - Promise-shaped
searchParams. The host page typically takes{ searchParams?: Promise<Record<...>> }(Next.js 16 contract). The dispatch route passes a plain object. The adapter wraps:searchParams: Promise.resolve(searchParams). Don’t skip the wrap — TS will catch it but the runtime error is opaque. - Self-import path. When the extracted code imports something now in the same package, prefer
./indexover@cinatra-ai/<slug>-connector— the latter is a tsconfig path alias that resolves to the same place but pnpm in some configurations refuses self-referential package specifiers, and the relative path is faster to read. revalidatePathcardinality. A page deletion undersrc/app/connectors/<slug>/invalidates that route specifically. Revalidate BOTH the new canonical path AND the old one (if any external link still points there); React Router doesn’t follow the dispatch route automatically.- Client components. If the host route had a
"use client"component colocated (e.g. a Copy-to-clipboard panel), copy it verbatim into the extension. The"use client"directive works the same way; only the file path changes. metadataexport. The legacy host route hadexport const metadata: Metadata = { title: "..." }. The dispatch route owns metadata via its owngenerateMetadata(). Don’t re-exportmetadatafrom the impl file — Next.js will warn but the dispatch metadata wins.- Stale
.nextcache after route deletion.rm -rf .next/dev/typesbetweenpnpm typecheckruns on the main worktree if the cache complains about removed files (Cannot find module '../../../src/app/connectors/<slug>/page.js'). The fix is to clear, not to add the file back.
Verification gates
Section titled “Verification gates”After the migration:
pnpm typecheckexit 0 — most common failure is a missed./indeximport (the self-referential one) or an unflippedrevalidatePathstring.src/lib/__tests__/connector-setup-pages-parity.test.ts(3/3 pass) — confirms the catalog descriptors and the loader map are still in sync.src/lib/__tests__/connector-dispatch-route.test.ts(7/7 pass) — confirms the dispatch route resolves the slug via the registry.node scripts/audit/agent-builder-enqueue-gate.mjs— unaffected by route work but a good sanity check the worktree is still clean.
Reference migrations
Section titled “Reference migrations”All landed during v5.4 (2026-05-19); diff for inspection:
| Slug | PR | Squash |
|---|---|---|
apify-connector | #505 | 97a22bfcc |
gmail-connector | #505 | 97a22bfcc |
tailscale-connector (with client component) | #505 | 97a22bfcc |
a2a-server-connector (with form actions, 264 LOC) | #504 | 2ca6f33be |
google-oauth-connector (setup-page-only, wraps @cinatra-ai/google-oauth-connection) | #504 | 2ca6f33be |
google-calendar-connector (folded Appointment Schedules into the same page) | #502 | ef4a405b1 |
gemini-connector | #502 | ef4a405b1 |
claude-connector (relocated src/lib/mcp-oauth-clients.ts into the extension) | #503 | b48f6197e |
The 7-connector “PR2 wave” (openai/apollo/linkedin/youtube/wordpress/drupal/github) used a SHORTER variant of this pattern: the extension already had a working settings-page.tsx, so the migration was just a 15-line setup-page.tsx wrapper around the existing named-export — no setup-impl extraction needed. Use the short variant whenever a working settings-page.tsx is already in the extension; use the full three-file split when extracting a host route.