< BLOG />

TanStack Start ¡Esto es la leche!

¿Una aplicación React que combine SPA, SSR y backend sin convertir el proyecto en un jeroglífico? Esto es lo que hemos encontrado al usar TanStack Start en proyectos reales.

< INICIO />

Intro

React es una librería ligera, y esa es una de sus grandes ventajas: puedes utilizarla casi en cualquier sitio y adaptarla a lo que necesites.

El problema aparece cuando quieres construir una aplicación completa. Entonces tienes que elegir y conectar distintas librerías para resolver el enrutado, la carga de datos, el renderizado en servidor o la lógica de backend. La alternativa es recurrir a un metaframework que reúna estas piezas.

En los últimos años, los metaframeworks han avanzado muchísimo, pero algunas decisiones también han generado polémica: modelos cada vez más complejos y soluciones demasiado ligadas a una plataforma concreta.

Y aquí entra TanStack Start, que reúne ideas que ya habían demostrado funcionar —routing tipado, loaders, precarga y funciones de servidor— en un metaframework práctico y poco ligado a una plataforma. Apenas ha pasado un año desde su Release Candidate y su crecimiento está siendo muy llamativo.

TL;DR;

¿Qué trae de bueno TanStack Start? Vamos a destacar las cosas que más nos han gustado:

  • Deja de escribir rutas a ciegas. Rutas, parámetros, enlaces y query params están tipados. Si cambias una URL o te equivocas al navegar, TypeScript te avisa antes de que llegue a producción.

  • No tienes que casarte con SSR, SSG o SPA. Puedes prerenderizar las páginas que te interesen, renderizar otras en el servidor y mantener zonas puramente cliente. La estrategia de renderizado se adapta a la aplicación, no al revés.

  • Adiós al useEffect para cargar datos. Los loaders resuelven la carga inicial antes de renderizar y permiten cachear, precargar e invalidar datos. Además, encajan de forma natural con TanStack Query cuando necesitas una gestión más avanzada.

  • La siguiente pantalla puede estar lista antes de que el usuario haga clic. TanStack Router puede precargar en segundo plano tanto el código como los datos cuando detecta que el usuario probablemente va a navegar a una ruta. El resultado es que el usuario tiene la sensacíon de que el sitio va a la velocidad del rayo.

  • Tu frontend también puede ser tu backend. Con las Server Functions ejecutas lógica en el servidor desde el mismo proyecto y con tipado de extremo a extremo. Puedes acceder a bases de datos, proteger operaciones o utilizar TanStack Start como BFF (Backend For Frontend) sin montar otra API porque sí.

  • Autenticación y lógica transversal sin convertir el proyecto en un puzle. Su sistema de middleware y contexto permite centralizar autenticación, permisos, logging o dependencias y reutilizarlos en rutas y Server Functions.

  • No esperes a tenerlo todo para mostrar la parte crítica. Con streaming y Suspense, el servidor puede enviar primero el contenido importante y completar después las partes más lentas. La página empieza a aparecer sin quedar bloqueada por una consulta concreta.

Además está muy bien planteado, nosotros llevamos meses usándolo para desarrollar aplicaciones reales y ha ido muy bien. Esto nos ha animado a impartir a finales de septiembre un curso: TanStack Start + Claude Code

En este post vamos a ir entrando en detalle en cada uno de estos puntos.

¿De dónde viene esto?

Detrás de TanStack está Tanner Linsley, y ese nombre te suena aunque no lo sepas: es el creador de TanStack y quien está detrás de React Query (hoy TanStack Query), React Table, React Virtual, TanStack Form y TanStack Router, librerías que a fecha de hoy las mantienen un equipo amplio de colaboradores.

Y esto es importante por una razón muy concreta: TanStack Start no parte de cero:se apoya en años de experiencia construyendo algunas de las librerías más utilizadas del ecosistema React..

Tan Stack Start es una capa fullstack encima de TanStack Router, que ya era uno de los routers con el tipado más completo del ecosistema React. Router pone el enrutado, los loaders, los search params tipados y la precarga; Start añade el servidor: SSR, streaming, server functions, middleware y despliegue.

La v1.0 Release Candidate se anunció en septiembre de 2025 y desde entonces el proyecto no ha parado: releases muy frecuentes y un Breakthrough of the Year en los Open Source Awards de 2026.

Aceptación

Una "pedrada" para medir el éxito de un proyecto, es ver las descargas, vamos a ver algunos datos curiosos en npm trends.

Lo primero, miremos cómo ha despegado TanStack Start. Medimos el paquete @tanstack/react-start, es decir, sólo la versión de React (también hay una para Solid):

Evolución de tanstack start en los últimos dos años, se dispara a mitad de 2026

Está muy bien ver esa gráfica, pero lo interesante es ponernos en contexto, para ver de qué magnitud estamos hablando, vamos a compararlo con Angular (@angular/core) y Vue (vue).

Frente a @angular/core despega en pocos meses, y se pone a la altura de vue en descargas

Las descargas de npm no son una medida exacta de adopción —incluyen instalaciones automáticas y otros factores—, pero sí nos sirven para ver la tendencia. Y en el caso de TanStack Start, el crecimiento es muy llamativo.

Y bueno, tampoco nos vengamos tan arriba, que en unos meses, todavía no ha pillado a Next.js, eso ya sería para nota...

Comparativa con Next.js, Next.js sigue estando encima

En cualquier caso, comparar TanStack Start con Next.js tiene algo de trampa. Next.js apareció en 2016 y lleva años siendo una de las opciones de referencia en React. Tiene mucho más recorrido, comunidad y presencia en proyectos reales, así que es lógico que siga por delante.

Lo llamativo es la velocidad con la que han crecido las descargas de su paquete durante los últimos meses.

Y cuando entras en detalle, se entiende el interés: TanStack Start ha tomado unas cuantas decisiones muy acertadas:

  • Poco atado a una plataforma. Start es Vite por debajo, con Nitro y adaptadores de despliegue, tira con: node, contenedores, Cloudflare, Netlify, Vercel o tu _VPS_de siempre. No hay una plataforma "bendecida comercialmente", aunque conviene saber que la integración y las capacidades concretas varían según el runtime.

  • Tiene un modelo mental que cabe en la cabeza. No necesitas adoptar React Server Components (su soporte existe, pero hoy es experimental y opcional) ni sembrar el proyecto de directivas "use client" / "use server". El modelo de carga y caché es explícito, y delegas en TanStack Query cuando necesitas más. Hay funciones que se ejecutan en el servidor y funciones que se ejecutan en el cliente, y tú sabes cuál es cuál.

  • No se ha olvidado de las SPA. Puedes utilizar SSR, streaming y Server Functions donde aporten valor sin renunciar al modelo de aplicación cliente de toda la vida. Esto le sienta especialmente bien a dashboards, back-offices, editores y aplicaciones con mucha interacción y estado en el navegador.

  • Ofrece tipado de extremo a extremo. Rutas, parámetros, query params, loaders y server functions comparten los mismos tipos sin generadores ni pasos intermedios, con validación en runtime en las entradas.

  • Permite una adopción incremental. Si ya tienes una SPA de React con TanStack Router y Query, buena parte del modelo te vale y puedes ir activando SSR ruta a ruta, no de golpe; eso sí, toca revisar qué código puede ejecutarse en servidor.

  • Encaja con el ecosistema TanStack. Si ya utilizas Query, Table, Form o Virtual, puedes seguir trabajando con las mismas librerías y patrones dentro de Start.

¿Y cuándo elegiría Next.js? Si el equipo ya tiene experiencia con él, necesitas la madurez de su ecosistema o React Server Components es una pieza central del proyecto. Para una aplicación muy orientada a servidor, Next.js puede seguir siendo la apuesta más segura; si quieres combinar capacidades full-stack con una arquitectura muy apoyada en el cliente, TanStack Start resulta especialmente interesante.

"Dolores de..." que arregla

Rutas tipadas

Lo que escuece: escribes la URL a mano, te comes una errata, y te enteras en producción. O peor: renombras /products a /catalog, tu editor no te dice nada, y ya tienes materia para divertirte un fin de semana con un pete en producción.

En TanStack Start defines las rutas mediante ficheros y TypeScript conoce automáticamente su estructura, parámetros y datos asociados:

// src/routes/products.$productId.tsx
import { createFileRoute } from "@tanstack/react-router";

export const Route = createFileRoute("/products/$productId")({
  component: ProductDetail,
});

function ProductDetail() {
  // productId: string — inferido de la ruta, sin castings
  const { productId } = Route.useParams();

  return <h1>Producto {productId}</h1>;
}

Y ahora la parte buena, navegar:

<Link to="/products/$productId" params={{ productId: '42' }}>
  Ver producto
</Link>

// ❌ Error de compilación: la ruta no existe
<Link to="/prodcuts/$productId" params={{ productId: '42' }} />

// ❌ Error de compilación: falta el parámetro productId
<Link to="/products/$productId" />

Esto no es un string con un tipo bonito por encima: es autocompletado real de todas las rutas de tu aplicación y error de compilación si te falta un parámetro.

Lo mismo aplica a los query params, que normalmente son tierra de nadie. Aquí se validan y se tipan:

import { z } from "zod";

const productSearchSchema = z.object({
  page: z.number().catch(1),
  filter: z.string().catch(""),
  sort: z.enum(["newest", "oldest", "price"]).catch("newest"),
});

export const Route = createFileRoute("/products")({
  validateSearch: productSearchSchema,
  component: ProductList,
});

function ProductList() {
  // page: number, filter: string, sort: 'newest' | 'oldest' | 'price'
  const { page, filter, sort } = Route.useSearch();

  return (
    <Link
      from={Route.fullPath}
      search={(prev) => ({ ...prev, page: prev.page + 1 })}
    >
      Siguiente página
    </Link>
  );
}

El .catch() de Zod es la guinda: si alguien te llega con ?page=patata, tu aplicación no revienta, usa el valor por defecto. La URL pasa de ser un string "bienintencionado" a ser estado de tu aplicación, validado y tipado.

Renderizado a la carta

Lo que escuece: montas una SPA y, con el proyecto ya avanzado, descubres que algunas páginas tienen que llegar renderizadas desde el servidor, ya sea por la carga inicial, el SEO o las previews en redes sociales. Convertir toda la aplicación a SSR puede ser un buen marrón y, para colmo, seguramente haya rutas que quieras mantener en el cliente.

En TanStack Start puedes combinar SSR por ruta, zonas puramente cliente y páginas prerenderizadas dentro del mismo proyecto:

export const Route = createFileRoute("/dashboard")({
  // Ni el loader ni el componente se ejecutan en el servidor
  ssr: false, // panel interno tras login: aquí priorizamos render de cliente
  component: Dashboard,
});

export const Route = createFileRoute("/products/$productId")({
  // En la primera petición, carga los datos en el servidor,
  // pero renderiza el componente en el cliente
  ssr: "data-only",
  component: ProductDetail,
});

Y si una página es estática, la prerenderizas en el build sin cambiar de framework:

// vite.config.ts
import { tanstackStart } from "@tanstack/react-start/plugin/vite";

export default defineConfig({
  plugins: [
    tanstackStart({
      prerender: {
        enabled: true,
        crawlLinks: true, // sigue los enlaces y prerenderiza lo que encuentre
        filter: ({ path }) => !path.startsWith("/dashboard"),
      },
    }),
  ],
});

Así podemos tener:

  • Una landing prerenderizada.
  • Una ficha de producto con SSR.
  • Un panel de administración renderizado en el cliente.

Todo convive en el mismo proyecto y podemos cambiar de estrategia sin cambiar de framework.

Loaders

Lo que escuece: el useEffect con los corchetes vacíos y un fetch dentro. Ya sabes lo que viene: estado de carga, estado de error, cleanup, doble ejecución en StrictMode, condiciones de carrera y posibles waterfalls, y un spinner que aparece después de que la pantalla ya esté montada.

¿Qué nos ofrece TanStack Start? Un loader se ejecuta antes de renderizar el componente:

export const Route = createFileRoute("/products")({
  loader: () => fetchProducts(),
  component: ProductList,
});

function ProductList() {
  // Los datos están disponibles y su tipo se infiere del loader
  const products = Route.useLoaderData();

  return (
    <ul>
      {products.map((p) => (
        <li key={p.id}>{p.name}</li>
      ))}
    </ul>
  );
}

Los estados de carga y error se gestionan en la ruta mediante pendingComponent y errorComponent, así que no tienes que mantenerlos a mano dentro de ProductList.

Hay un detalle importante: los loaders son isomórficos. Con SSR, el loader se ejecuta en el servidor durante la primera petición, pero las navegaciones posteriores lo lanzan desde el cliente. Por eso no debes acceder directamente desde un loader a secretos o a la base de datos; para eso puedes llamar a una Server Function.

Si la carga depende de los query params, lo declaras con loaderDeps. Cuando cambien esas dependencias, el router volverá a ejecutar el loader:

export const Route = createFileRoute("/products")({
  loaderDeps: ({ search: { page, filter } }) => ({ page, filter }),
  loader: ({ deps: { page, filter } }) => fetchProducts({ page, filter }),
});

Y si necesitas ir más allá con la caché, invalidar consultas o hacer mutaciones optimistas, puedes combinar los loaders con TanStack Query.

export const Route = createFileRoute("/products")({
  loader: ({ context: { queryClient } }) =>
    queryClient.query({ ...productsQueryOptions(), staleTime: "static" }),
  component: ProductList,
});

function ProductList() {
  const { data } = useSuspenseQuery(productsQueryOptions());
  // ...
}

El loader deja los datos preparados en la caché y el componente los recupera mediante TanStack Query. Así puedes aprovechar los loaders del router sin renunciar a la caché, la invalidación y las mutaciones de Query.

Con staleTime: "static" el dato nunca se considera obsoleto, así que el loader devuelve lo que ya haya en caché y sólo lanza la petición si no hay nada. Si prefieres que revalide en segundo plano cuando el dato esté caducado, pon ahí el staleTime que te interese. En versiones anteriores de TanStack Query esto se escribía con ensureQueryData, hoy marcado como obsoleto en favor de queryClient.query.

La siguiente pantalla, antes del clic

Lo que escuece: haces clic en un enlace y es justo entonces cuando la aplicación empieza a cargar la siguiente pantalla. Primero el código, después los datos y, mientras tanto, un spinner.

TanStack Router puede adelantarse y precargar el código y los datos de la ruta cuando detecta que probablemente vas a navegar hasta ella.

const router = createRouter({
  routeTree,
  defaultPreload: "intent", // Precarga una ruta cuando detecta intención de navegar hasta ella
  defaultPreloadDelay: 100, // espera 100 ms para evitar precargas accidentales
});

Con intent, el router empieza a precargar cuando el usuario mantiene el cursor sobre un enlace, lo enfoca o empieza a tocarlo.

También puedes ajustar la precarga en cada enlace:

<Link to="/products" preload="viewport" />  {/* al entrar en pantalla */}
<Link to="/checkout" preload="render" />    {/* en cuanto se pinta el enlace */}
<Link to="/heavy" preload={false} />        {/* aquí no, gracias */}

El resultado es una navegación que se percibe mucho más rápida. El impacto real dependerá de la aplicación, la red y el comportamiento de los usuarios, pero la mejora en la experiencia puede ser importante.

Server Functions

Lo que escuece: necesitas leer datos de la base de datos, así que montas un endpoint y replicas su contrato en el cliente. El día que la respuesta cambia en el servidor pero no actualizas el cliente, tienes dos versiones de la verdad. Hay formas de mantenerlas sincronizadas, claro, pero requieren añadir más piezas.

Con una Server Function (una función normal que sólo se ejecuta en el servidor), los parámetros y la respuesta mantienen el mismo tipo de un extremo al otro. La lógica se ejecuta únicamente en el servidor, aunque puedes llamarla desde distintas partes de la aplicación:

// src/server/products.ts
import { createServerFn } from "@tanstack/react-start";
import { z } from "zod";
import { db } from "./db";

export const getProduct = createServerFn({ method: "GET" })
  .validator(z.object({ id: z.string() }))
  .handler(async ({ data }) => {
    // Esto NO viaja al bundle del cliente
    return db.product.findUniqueOrThrow({ where: { id: data.id } });
  });

Por ejemplo, podemos llamarla desde el loader de una ruta:

export const Route = createFileRoute("/products/$productId")({
  loader: ({ params }) => getProduct({ data: { id: params.productId } }),
  component: ProductDetail,
});

El tipo de retorno de getProduct es el que devuelve tu consulta a base de datos. No hay any ni generador de tipos que ejecutar, y no duplicas el tipo de la respuesta a mano. Si mañana quitas un campo del select, el componente que lo pintaba deja de compilar.

Ojo con el matiz: esto no te obliga a tirar tu backend. Puedes utilizar Start como BFF (Backend for Frontend) delante de tus microservicios y mantener el tipado entre la aplicación React y esa capa. Eso sí, una Server Function sigue siendo un endpoint accesible por red, así que necesita autenticación, autorización y validación como cualquier otro.

Middleware

Lo que escuece: recuperar la sesión y comprobar el usuario acaba siendo el prólogo de cada ruta, endpoint y operación protegida. Repites el mismo código por todo el proyecto y cualquier cambio obliga a revisarlo en varios sitios.

Con los middleware de Start puedes centralizar esa comprobación y hacer que las Server Functions reciban el usuario autenticado, además ya tipado:

import { createMiddleware } from "@tanstack/react-start";

const authMiddleware = createMiddleware({ type: "function" }).server(
  async ({ next }) => {
    const user = await getUserFromSession();
    // Primero comprobamos que el usuario está autenticado
    if (!user) throw new Error("No autorizado");

    // Las Server Functions que usen este middleware recibirán context.user tipado
    return next({ context: { user } });
  },
);

Después, puedes aplicarlo únicamente a las Server Functions que lo necesiten:

export const deleteProduct = createServerFn({ method: "POST" })
  .middleware([authMiddleware])
  .validator(z.object({ id: z.string() }))
  .handler(async ({ data, context }) => {
    // context.user existe y está tipado. No hay que comprobarlo otra vez.
    if (context.user.role !== "admin") throw new Error("Prohibido");
    await db.product.delete({ where: { id: data.id } });
  });

Aquí hay dos comprobaciones distintas. El middleware confirma que hay un usuario autenticado; después, la Server Function comprueba si ese usuario tiene permisos para borrar productos. Estar identificado no significa estar autorizado para cualquier operación.

Para tareas que afectan a toda la aplicación, como logging, tracing o detección de idioma, puedes registrar un middleware global:

// src/start.ts

import { createCsrfMiddleware, createStart } from "@tanstack/react-start";

// Este middleware lo hemos creado nosotros
import { miLoggingMiddleware } from "./middleware/mi-logging-middleware";

const csrfMiddleware = createCsrfMiddleware({
  filter: (context) => context.handlerType === "serverFn",
});

export const startInstance = createStart(() => ({
  requestMiddleware: [miLoggingMiddleware, csrfMiddleware],
}));

Hay un detalle importante: si no tienes un src/start.ts, Start protege automáticamente las Server Functions frente a peticiones enviadas desde otras webs. Al crear este fichero, debemos añadir expresamente createCsrfMiddleware(), como en el ejemplo. Este middleware evita ataques CSRF, en los que otra web intenta realizar una operación en nuestra aplicación aprovechando que el usuario ya tiene una sesión abierta.

Si vienes de Express, Koa o Fastify, esto te va a sonar: una cadena de funciones que envuelve al handler y en la que cada una decide si continúa con next() o corta la petición. En Start, esa misma idea vive dentro del proyecto React, puede aplicarse a distintos niveles y permite pasar contexto tipado de un middleware al siguiente.

Streaming

Lo que escuece: navegas a la página de detalle de un producto. La ficha está lista enseguida, pero las reseñas tardan tres segundos. Si esperas a tenerlo todo, esa consulta lenta retrasa la página entera.

En TanStack Start puedes esperar únicamente los datos necesarios para mostrar la página y devolver el resto como promesas todavía pendientes. El servidor empieza a enviar la parte que ya tiene lista y completa después las zonas que dependen de esas promesas mediante streaming:

export const Route = createFileRoute("/products/$productId")({
  loader: async ({ params }) => {
    // Lo lento: NO lo esperamos
    const reviewsPromise = fetchReviews(params.productId);
    const relatedPromise = fetchRelated(params.productId);

    // Lo importante: esto sí
    const product = await getProduct({ data: { id: params.productId } });

    return { product, reviewsPromise, relatedPromise };
  },
  component: ProductDetail,
});

Y en el componente, Await pinta cada trozo cuando llega:

import { Await } from "@tanstack/react-router";

function ProductDetail() {
  const { product, reviewsPromise, relatedPromise } = Route.useLoaderData();

  return (
    <>
      <ProductHeader product={product} />

      <Await promise={reviewsPromise} fallback={<ReviewsSkeleton />}>
        {(reviews) => <ReviewList reviews={reviews} />}
      </Await>

      <Await promise={relatedPromise} fallback={<RelatedSkeleton />}>
        {(related) => <RelatedProducts items={related} />}
      </Await>
    </>
  );
}

Fíjate en lo que ocurre: ¡bam!, la ficha del producto aparece sin esperar a las reseñas ni a los productos relacionados. Mientras esas dos consultas terminan, el usuario ve sus respectivos skeletons. En cuanto llegan las reseñas, se muestran; y los productos relacionados harán lo mismo cuando estén listos. Cada bloque avanza por separado, sin bloquear a los demás.

En React 19 también puedes resolver estas promesas con el hook use() y una frontera de Suspense. Aquí utilizamos Await porque deja el ejemplo más compacto y funciona también con React 18.

¿Y si la promesa falla? Await lanza el error ya serializado y lo recoge la frontera de error más cercana. Puedes gestionarlo con el errorComponent de la ruta o colocar una frontera específica alrededor de ese bloque para mostrar un mensaje más útil.

Conclusión

Después de probarlo, una de las cosas que más nos ha gustado es lo fácil que resulta seguir el recorrido de los datos. Un loader los carga, una Server Function ejecuta la lógica en el servidor y el componente se ocupa de mostrarlos.

Por debajo hay compilación, serialización e hidratación, claro, pero las APIs dejan bastante claro qué se ejecuta en el servidor y qué llega al cliente. Además, el tipado conecta las rutas, los parámetros y los datos, por lo que muchos errores aparecen mientras estás escribiendo el código.

Esto también viene muy bien cuando trabajas con asistentes como Claude Code. Si genera una ruta que no existe o se deja un parámetro, TypeScript se lo marca y le da una pista bastante concreta para corregirlo.

Por eso, a finales de septiembre impartimos TanStack Start + Claude Code. Construiremos una aplicación full-stack tipada de principio a fin y utilizaremos Claude Code durante el desarrollo para ver en qué tareas resulta útil y cómo aprovechar el tipado para trabajar con él.

El curso es parcialmente bonificable a través de FUNDAE.

Muchas gracias por haber llegado hasta aquí :)

¿Necesitas ayuda con tu proyecto?

En Lemoncode llevamos años diseñando y desarrollando aplicaciones para proyectos reales. Ayudamos con arquitectura, desarrollo front-end y full-stack, autenticación, gestión de datos, rendimiento, despliegue y esas partes que suelen complicarse cuando una aplicación empieza a crecer.

TanStack Start es una de las tecnologías que estamos utilizando activamente, pero no intentamos encajarlo en todas partes. Analizamos cada proyecto y elegimos las herramientas que mejor resuelven el problema.

Si necesitas arrancar una aplicación, mejorar una que ya está en marcha o tomar una decisión técnica con más seguridad, podemos ayudarte. Escríbenos a info@lemoncode.net y cuéntanos tu caso.