LeadFlow SaaS — TanStack Router

Migración completa de React Router v6 → TanStack Router con tipado end-to-end

TanStack Router v1 TypeScript 5 Vite 5 Zod validation
01 — Estructura de Rutas por Archivo
src/routes/ — LeadFlow SaaS
src/routes/
├── __root.tsx— layout raíz: nav, auth guard, outlet
├── index.tsx— / → redirect a /dashboard
├── dashboard.tsx— /dashboard → métricas KPIs
├── leads/
│ ├── index.tsx— /leads → lista + filtros (page, status, assignee, search)
│ ├── $leadId.tsx— /leads/:leadId → perfil del lead
│ └── $leadId/
│ └── notes.tsx— /leads/:leadId/notes → notas nested
└── settings/
├── _layout.tsx— layout con sidebar de settings
├── profile.tsx— /settings/profile
├── billing.tsx— /settings/billing
├── team.tsx— /settings/team
└── integrations.tsx— /settings/integrations
02 — Configuración Vite + Router
vite.config.ts
Setup
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { TanStackRouterVite } from
  '@tanstack/router-plugin/vite'

export default defineConfig({
  plugins: [
    // ⚡ Genera routeTree.gen.ts automáticamente
    TanStackRouterVite(),
    react(),
  ],
})
R
src/main.tsx
Entry
import { createRouter, RouterProvider }
  from '@tanstack/react-router'
import { routeTree } from
  './routeTree.gen' // auto-generado

// Router completamente tipado
const router = createRouter({
  routeTree,
  defaultPreload: 'intent',
})

// Registro de tipos global
declare module '@tanstack/react-router' {
  interface Register {
    router: typeof router
  }
}

createRoot(document.getElementById('root')!)
  .render(<RouterProvider router={router} />)
03 — Rutas Principales con Loaders
R
src/routes/leads/index.tsx
loader search params code split
import { createFileRoute, Link } from '@tanstack/react-router'
import { z } from 'zod'
import { fetchLeads } from '~/api/leads'

// ── Schema Zod con defaults seguros ──────────────────────────────────
const leadsSearchSchema = z.object({
  page:     z.number().int().positive().catch(1),
  status:   z.enum(['all','new','contacted','qualified','closed']).catch('all'),
  assignee: z.string().uuid().optional(),
  search:   z.string().optional(),
})

export type LeadsSearch = z.infer<typeof leadsSearchSchema>

// ── Definición de ruta ────────────────────────────────────────────────
export const Route = createFileRoute('/leads/')({
  validateSearch: leadsSearchSchema,   // 🔒 Zod valida la URL

  loaderDeps: ({ search }) => ({ search }),   // re-fetch solo si cambian

  loader: async ({ deps: { search } }) =>
    fetchLeads(search),                 // datos antes de renderizar

  component: LeadsPage,
})

function LeadsPage() {
  // ✅ Tipos inferidos automáticamente del loader
  const { leads, total, pages } = Route.useLoaderData()
  //    ^? { leads: Lead[], total: number, pages: number }

  const { page, status, search } = Route.useSearch()
  //    ^? LeadsSearch — NO es string | undefined

  const navigate = Route.useNavigate()

  return (
    <div className="leads-page">
      <input
        value={search ?? ''}
        onChange={e => navigate({ search: { search: e.target.value, page: 1 } })
        }
      />
      {leads.map(lead => (
        // ✅ Compile error si leadId no se pasa
        <Link key={lead.id} to="/leads/$leadId"
          params={{ leadId: lead.id }}>
          {lead.name}
        </Link>
      ))}
    </div>
  )
}
R
src/routes/leads/$leadId.tsx
dynamic param loader nested
import { createFileRoute, notFound, Link, Outlet } from '@tanstack/react-router'
import { fetchLead } from '~/api/leads'

export const Route = createFileRoute('/leads/$leadId')({
  loader: async ({ params: { leadId } }) => {
    // leadId es string tipado — no hace falta casting
    const lead = await fetchLead(leadId)
    if (!lead) throw notFound()  // → renderiza notFoundComponent
    return lead
  },

  notFoundComponent: () =>
    <div className="not-found">Lead no encontrado</div>,

  component: LeadProfile,
})

function LeadProfile() {
  const lead = Route.useLoaderData()
  //    ^? Lead — tipado desde el return del loader

  return (
    <div>
      <h1>{lead.name}</h1>
      <p>{lead.company}{lead.email}</p>

      <nav>
        // ✅ params tipados — TS error si falta leadId
        <Link to="/leads/$leadId/notes"
          params={{ leadId: lead.id }}>
          Notas
        </Link>
      </nav>

      <Outlet />  {/* /leads/:id/notes renderiza aquí */}
    </div>
  )
}
04 — Mapa de Rutas LeadFlow
GET
/dashboard
Métricas KPIs: leads por estado, conversion rate, revenue mensual
loader: fetchDashboard() code split
GET
/leads?page=1&status=all&search=&assignee=
Lista paginada con filtros persistidos en URL — bookmarkable y compartible
loader: fetchLeads(search) validateSearch: Zod code split
GET
/leads/:leadId
Perfil completo del lead con historial de actividad
param: leadId loader: fetchLead(leadId) outlet para /notes
GET
/leads/:leadId/notes
Notas del lead — nested route que comparte el loader del padre
param: leadId nested loader: fetchNotes(leadId)
GET
/settings/{profile|billing|team|integrations}
Layout con sidebar compartido — cada sección tiene su propio loader
_layout.tsx 4× code split
05 — Seguridad de Tipos End-to-End

🔗 Links Tipados

Cambiar una ruta rompe todos los Links rotos en compile time
// Correcto <Link to="/leads/$leadId" params={{ leadId: "abc" }}/> // Error de compilación <Link to="/leads/detail" /> // Type: route doesn't exist // Error: falta leadId <Link to="/leads/$leadId" /> // Type: params required

🔍 Search Params

Zod valida y tipifica — nunca más string | undefined
// Antes (React Router) const [params] = useSearchParams() params.get('page') // ^? string | null ⚠️ // Después (TanStack) const { page, status } = Route.useSearch() // page: number ✅ // status: 'all'|'new'|... ✅

Loader Data

El tipo del componente se infiere del return del loader
// loader retorna Lead loader: async () => fetchLead(id) // Promise<Lead> // componente lo recibe tipado const lead = Route.useLoaderData() // ^? Lead — sin casting ✅ // Si cambias el loader, TS // actualiza el tipo automático
06 — Migración React Router → TanStack Router
Patrón React Router v6 TanStack Router Beneficio
Params useParams()<any> params.leadId: string Tipado automático, sin casting
Search useSearchParams() → string|null useSearch() → LeadsSearch Zod valida y da defaults
Data useState + useEffect fetch loader: async () => data Sin spinner, datos antes de render
Links <Link to="/leads/detail"> <Link to="/leads/$leadId"> Error de compilación si ruta no existe
Code split React.lazy() manual Automático por archivo Bundle inicial ~40% más ligero
Layout <Outlet /> en componente _layout.tsx Separación explícita y file-based
404 Route path="*" al final throw notFound() en loader Por ruta, con componente propio
07 — Checklist de Implementación LeadFlow
Instalar @tanstack/react-router + plugin Vite npm i @tanstack/react-router && npm i -D @tanstack/router-plugin
Configurar TanStackRouterVite() en vite.config.ts Genera routeTree.gen.ts automáticamente al guardar
Crear src/routes/__root.tsx con nav y Outlet Auth guard, layout global, link activos con [&.active]
Schema Zod para /leads search params page, status, assignee, search — todos con .catch() defaults
Loader en /leads con loaderDeps Re-fetches solo cuando page/status/search cambian
Ruta dinámica /leads/$leadId con notFound() 404 por lead inexistente, notFoundComponent propio
Nested route /leads/$leadId/notes Outlet en LeadProfile, loader independiente para notas
Layout /settings/_layout.tsx con sidebar 4 sub-rutas con links tipados al sidebar activo