skills/migrate-to-tanstack/SKILL.md
--- name: migrate-to-tanstack description: Migrate an existing web app to the canonical TanStack Start + Drizzle + D1 + Cloudflare Workers stack. Use when user wants to migrate, port, move, rewrite, or convert an app to TanStack Start — from Next.js, Vite+Hono, Remix, Nuxt, Express, Fly.io, Vercel, or any other stack. category: project-setup argument-hint: [--strategy branch|parallel|fresh] [--keep-data] allowed-tools: Bash(pnpm *) Bash(pnpx *) Bash(wrangler *) Bash(git *) Bash(grep *) Bash(jq *
npx skillsauth add RonanCodes/ronan-skills skills/migrate-to-tanstackInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Port an existing app to TanStack Start + Drizzle + D1 + Cloudflare Workers. Different shape from new-tanstack-app: audit first, port incrementally, cut over last.
/ro:migrate-to-tanstack # ask strategy, run audit
/ro:migrate-to-tanstack --strategy branch # migrate in a branch of current repo
/ro:migrate-to-tanstack --strategy parallel # create a sibling directory
/ro:migrate-to-tanstack --strategy fresh # brand new repo, port source in
/ro:migrate-to-tanstack --keep-data # plan data migration, not just schema
Also useful when keeping the source framework. If the user wants to keep Astro / Vite / Remix / etc. and only swap host to CF Workers, the host-adapter +
wrangler.jsonc+ GH Actions +gh secret set --env production+ DNS-cutover steps in this skill (§ 0 credential-source, § 9 cut-over, "Custom domain: pre-delete conflicting DNS") still apply 1:1. Skip steps 4-7 (schema/server/UI/auth port) and treat it as a host migration.
Before any "ask the user for an API token" step, grep ~/.claude/.env for what's already onboarded. The user has a single env file with section headers per provider (CF, Sentry, PostHog, Neon, Uptimerobot, ElevenLabs, Anthropic, OpenAI, Knock, etc.) — token-paste UX is friction when the value is already there from a prior project.
grep -iE "^(CLOUDFLARE_|SENTRY_|POSTHOG_|UPTIMEROBOT_|NEON_|KNOCK_|ANTHROPIC_|OPENAI_|GOOGLE_GENERATIVE_|GH_TOKEN|GITHUB_TOKEN)=" ~/.claude/.env
When sourcing into the shell:
set -a && source "$(ro context env)" && set +a
unset GH_TOKEN GITHUB_TOKEN # required — see "GITHUB_TOKEN gotcha" in new-tanstack-app §13
Only ask the user to paste a token if grep returns nothing for that provider. This applies equally to: scaffolding new apps, migrations, audits, ad-hoc deploys.
Report current stack before touching anything. Probe these files in parallel:
package.json: framework (next, @remix-run, vite, nuxt, @tanstack/start, hono, express), ORM (prisma, drizzle-orm, @neondatabase/serverless, raw pg/better-sqlite3), auth (@workos-inc/node, @workos-inc/authkit-react, better-auth, @clerk/*, next-auth, @auth/core, lucia)fly.toml / vercel.json / netlify.toml / wrangler.toml — deploy target.env* files — current secrets surface (don't print values, just keys)prisma/schema.prisma, db/schema.ts, drizzle.config.ts — schema sourcesrc/routes/ vs pages/ vs app/ — routing conventiontsconfig.json — strict flags already set?Produce an audit report:
Current stack:
Framework: <e.g. Vite + Hono>
Routing: <file-based / programmatic>
ORM: <Drizzle / Prisma / raw>
DB: <SQLite-on-disk / Postgres / D1>
Auth: <Clerk / NextAuth / WorkOS / Better Auth / rolled-own>
Deploy: <Fly / Vercel / Cloudflare / other>
LOC: <routes>, <components>, <server>
Tests: <Vitest / Jest / Playwright / none>
If --strategy wasn't given, ask the user via AskUserQuestion:
../<app>-tanstack, port source in, cut over by renaming dirs. Good when the old stack must keep running during migration.Tag the current state before any changes:
git tag pre-tanstack-migration && git push --tags
Delegate to /ro:new-tanstack-app <app-name> --skip-deploy for the skeleton. This gives you wrangler.toml, Drizzle, Zod, shadcn, hygiene config, testing setup — all in the target location.
db/schema.ts as-is, swap dialect to 'sqlite' if moving from Postgres (note: review JSONB, array columns, Postgres-specific types — SQLite uses TEXT for JSON).schema.prisma to Drizzle manually (or use prisma-to-drizzle). Keep naming identical so queries port 1:1 later.Run pnpm drizzle-kit generate + wrangler d1 migrations apply --local to verify the schema builds against D1.
Map source → target:
| From | To |
|---|---|
| Next.js API route (app/api/*/route.ts) | TanStack Server Route (src/routes/api/*.ts) |
| Next.js RSC / action | TanStack Server Function (createServerFn) |
| Hono route (app.get('/x', ...)) | Server Route with method handlers |
| Express route | Server Route (hand-port the handler body) |
| Remix loader / action | Server Function or Server Route |
Port one surface at a time. After each, run the test suite against it.
next/link → @tanstack/react-router). Move data-fetching out of RSC/loaders into TanStack Router loader/beforeLoad or server functions.pnpm dlx shadcn@latest add <comp>.Default target is Clerk (hosted UI components, free to 10K MAU, fastest first sign-in). Flip to WorkOS AuthKit when 100K+ MAU is plausible, a non-engineer partner needs the Admin Portal, or near-term SAML SSO is on the roadmap. Flip to Better Auth when the target app must own the users table, has an EU residency mandate, needs custom auth flows neither vendor can bend to, or wants zero vendor lock-in.
<ClerkProvider /> at app root, copy authenticateRequest() calls into route loaders, update Clerk dashboard's allowed origins for the new domain.withAuth route guards, update redirect URIs for the new domain.lib/auth.ts, re-mount at src/routes/api/auth/$.ts.Delegate to /ro:clerk (default), /ro:workos (alt-at-scale), or /ro:better-auth (alt-optionality). If neither exists, inline the wiring.
--keep-data)Strategy by source DB:
sqlite3 db.sqlite .dump > dump.sql, clean Postgres-isms if any, wrangler d1 execute <db> --remote --file=dump.sql.pg_dump --data-only --column-inserts, rewrite type-incompatible inserts (UUIDs → TEXT, JSONB → JSON string, timestamps → integer ms), apply via wrangler d1 execute --file.pg_dump | psql against the new Neon URL. Simpler path if schema leans on Postgres features.Always dry-run against --local D1 first.
In order:
/ro:cf-shipwrangler secret put each one)/ro:cloudflare-dns)flyctl apps destroy; Vercel: delete project) only after confirming the new one is stableSummarise: strategy used, current state, what's ported, what's left, rollback command (git reset --hard pre-tanstack-migration or DNS swap back).
Hard-won patterns — read before skipping.
git tag pre-tanstack-migration alone is enough for rollback, but GitHub's branch-compare UI only works for branches. Also create git branch pre-tanstack-migration pre-tanstack-migration && git push origin pre-tanstack-migration — future-you will want to diff the migration in GitHub without cloning.
VITE_*Default TanStack scaffolds hard-code VITE_SENTRY_DSN and VITE_POSTHOG_PROJECT_API_KEY into the bundle at build time. For a public-facing app, prefer runtime injection:
vars in wrangler.jsonc (public by design — Sentry DSNs and PostHog phc_ keys ship to browsers)src/routes/api/config.ts that returns { sentryDsn, posthogKey, posthogHost } from envsrc/lib/runtime-config.ts with a memoised client-side fetch('/api/config')initSentry() / initPostHog() as async, read from runtime-config, no-op if keys are emptyTrade-off: first paint does one extra fetch before analytics init. Benefits: keys rotate without rebuild, CI builds without secrets, forks don't ship your keys. Document in ARCHITECTURE.md.
wrangler types and no-unnecessary-conditionIf wrangler.jsonc has vars with string defaults ("SENTRY_DSN": ""), wrangler types generates literal types (SENTRY_DSN: ""). Subsequent env.SENTRY_DSN ?? '' becomes a lint error (TS knows it's always truthy/always empty-string). Fix: use the env value directly, no fallback — the Worker's runtime vars will override the default at deploy time anyway.
Add worker-configuration.d.ts to the eslint ignores list — it's autogenerated and full of benign patterns that trip strict rules.
If you don't have a dedicated vitest.config.ts, vitest loads vite.config.ts which pulls in @cloudflare/vite-plugin → startup fails with TypeError: require_react is not a function from the workers runner pool. Fix: create a minimal vitest.config.ts with environment: 'jsdom' and passWithNoTests: true. Do NOT share vite.config.
quality script, one CI stepCollapse the local quality gate into a single script:
"quality": "pnpm run format && pnpm run lint && pnpm run build && pnpm run test"
CI runs the same script — no drift between local and CI. pnpm test:e2e and integration tests stay separate (they need a running server).
When attaching a Worker to a domain that previously pointed at another host (Fly, Vercel, etc.), PUT /accounts/:id/workers/domains fails with error 10007 "Hostname already has externally managed DNS records". override_existing_dns_record: true does NOT reliably work. Fix: DELETE the old A/AAAA records first, THEN attach the Worker domain.
workers.dev subdomain rename is dashboard-onlyThe default <account>-<slug>.workers.dev hostname can only be renamed via the Cloudflare dashboard, not the API. When running wrangler deploy for the first time, the account's workers.dev subdomain is auto-chosen from the account name — pick the account alias carefully before the first deploy if you care about the URL.
deploy:
needs: test
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
environment: production
concurrency: { group: deploy-production, cancel-in-progress: false }
needs: test is the gate. cancel-in-progress: false on deploy prevents a second push from cancelling a deploy mid-flight. Pass observability secrets via wrangler deploy --var KEY:"$KEY" — reads from GitHub Actions secrets, never written to the bundle.
server.handlersHono's app.get('/x', h) → createFileRoute('/api/x')({ server: { handlers: { GET: h } } }). Note: Cloudflare bindings come from import { env } from 'cloudflare:workers' — there's no c.env equivalent in handlers. Hand-port the body; don't try to share code between the two shapes.
/ro:new-tanstack-app — the scaffold this skill invokes/ro:cf-ship — ships the migrated app/ro:cloudflare-dns — DNS cutover/ro:commit — per-step commits during portingtesting
--- name: linear-pipeline description: The Fable orchestrator for a single dispatched Linear ticket. Holds almost no context itself; it receives `--issue <ID> --detached`, decides the stage sequence, and fans out a sub-agent per stage, passing forward only each stage's artifact (never re-derived, never inlined into its own context). Step zero, before any planning or stage routing, is a boundary triage against `canon/security-boundary.md` (#199): a match tags Ronan Connolly and stops the run, no
development
--- name: in-your-face description: Capture a chat-only answer into a durable artifact (markdown + HTML, PDF when cheap) and launch it automatically so the user cannot miss it. Use when user says "in your face", "don't let me lose this", "save that answer", "make that durable", or right after answering a substantive side question (a recipe, comparison, how-to, or generated prompt) that would otherwise die with the context. category: workflow argument-hint: [--no-open] [--vault <short>] [hint of
tools
One-shot headless OpenAI Codex CLI calls for background/admin AI tasks — summaries, classification, extraction, admin glue. The default engine for anything that runs AI constantly in the background (daemon-driven, per-event), because it bills the flat ChatGPT subscription instead of Claude usage or per-token API spend, and it keeps working while Claude is rate-limited. NEVER for coding — coding stays Claude. Use when a skill or daemon needs a cheap always-on AI call, when the user says "use codex", "ask codex", "codex as backup", or when building a background summarizer/classifier into a listener or loop. Reads auth from ~/.codex/auth.json (ChatGPT account, no API key).
research
Turn a warranty rejection, repair quote, or RMA email into a cited decision brief — legal read (NL/EU consumer law), is the part user-serviceable, live part and new-unit prices, repair-vs-DIY-vs-new economics, before-you-send-it checklist, deadlines. Use when the user pastes or screenshots a repair quote, warranty rejection, "not covered" email, onderzoekskosten fee, or asks "should I repair or replace this".