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 portingdevelopment
Close the loop on a Linear ticket when its work ships - move the status and post a deploy comment with the PR link, what shipped, and a try-it link, mentioning the collaborator. Used as the tail of /ro:linear-nightshift for every merged mirror, or manually after an ad-hoc build. Triggers on "linear update", "update the linear ticket", "mark NUT-x done", "tell eoin it shipped", "/ro:linear-update".
devops
Run a night-shift against a collaborator's Linear board. Pulls the team's Grilled tickets (/ro:linear-grill moves a ticket to Grilled once its questions are answered), VERIFIES the questions were actually answered (unanswered → bounce the ticket to the "Question for <name>" state), mirrors verified tickets to ephemeral GitHub issues with ready-for-agent, then runs the standard /ro:night-shift machinery on GitHub. Tail-calls /ro:linear-update for everything that merged + deployed. Triggers on "linear nightshift", "nightshift linear", "drain the linear board", "run the shift off linear", "/ro:linear-nightshift".
development
Grill a collaborator's Linear tickets and move every processed ticket to where it belongs. Resolves the board from the repo's .ro-linear.json, reads the collaborator's Backlog / Ready-for-agent issues, then per ticket either posts 3-5 decision-extracting questions (state moves to "Question for <name>") or confirms it build-ready (state moves to "Grilled", the gate /ro:linear-nightshift consumes); shipped-and-confirmed tickets close as Done. The async-collaborator counterpart of /ro:day-shift for people who never touch GitHub. Triggers on "grill linear", "grill eoin's tickets", "linear grill", "add questions to the linear tickets", "/ro:linear-grill".
development
--- name: about-page description: Add a standard About page to any web app, what it is, the tech stack, and an FAQ, wired into a footer link with a sticky footer. Built with Spartan + Tailwind (the canonical component layer) and falls back to semantic HTML so it ships reliably. Use whenever building, polishing, or shipping an app, every app should have one. Triggers on "add an about page", "about page", "footer about link", or as a standard step in app build/polish. category: frontend argument-h