Saltearse al contenido

Empezando con TanStack Start

En esta guía vas a crear un pequeño blog con TanStack Start (React) que lee sus posts de Content Island:

  • Una página de inicio con el listado de todos los posts.
  • Una página de detalle por post, en /posts/<slug>, que pinta su contenido Markdown.

Por el camino verás lo básico que necesitas en cualquier proyecto TanStack Start: server functions, loaders de ruta y cómo encajan para mantener el token de acceso en el servidor.

Requisitos previos

Prepara tu contenido en Content Island

  1. Crea un proyecto nuevo desde la página de Proyectos.

  2. En la pestaña Modelo, crea un modelo llamado post con estos campos:

    Nombre del campoTipoNotas
    titleTexto CortoObligatorio.
    slugTexto CortoObligatorio y único.
    contentTexto LargoCuerpo del post en Markdown.
  3. En la pestaña Contenido, añade dos o tres posts y publícalos.

  4. En la pestaña General, copia el Token del proyecto (el token de acceso al API).

Crea el proyecto TanStack Start

  1. Crea un proyecto nuevo:

    Ventana de terminal
    npx @tanstack/cli create my-blog
    cd my-blog
  2. Instala el cliente de Content Island y marked (para convertir el Markdown a HTML):

    Ventana de terminal
    npm install @content-island/api-client marked

Configura el token de acceso

El token de acceso da acceso a tu proyecto, así que nunca debe llegar al navegador. Guárdalo en una variable de entorno:

.env
CONTENT_ISLAND_ACCESS_TOKEN=tu-token-de-acceso

TanStack Start carga el fichero .env en process.env por ti, así que el token está disponible en tu código de servidor sin paquetes extra. En producción, define la misma variable en tu proveedor de hosting.

Crea el cliente

Crea el cliente una sola vez y reutilízalo desde tus server functions:

src/server/content-island.ts
import { createClient } from '@content-island/api-client';
export const client = createClient({
accessToken: process.env.CONTENT_ISLAND_ACCESS_TOKEN!,
});

Y un tipo que refleje el modelo post:

src/models/post.ts
export interface Post {
id: string;
title: string;
slug: string;
content: string;
}

Server functions y loaders

Antes de escribir páginas, conviene entender las dos piezas que vas a usar para cargar datos:

loader de rutaServer function (createServerFn)
¿Dónde se ejecuta?En el servidor en la primera petición y en el navegador al navegar en cliente.Siempre en el servidor.
¿Puede usar el token?No (su código también se envía al navegador).Sí.
¿Cómo se llama?Automáticamente, el router lo ejecuta antes de pintar la ruta.Como una función async normal, desde cualquier sitio. Desde el navegador se convierte en un fetch a tu servidor.

Así que el patrón es: lee Content Island dentro de server functions y llama a esas server functions desde tus loaders.

src/server/posts.ts
import { createServerFn } from '@tanstack/react-start';
import { marked } from 'marked';
import { client } from './content-island';
import type { Post } from '../models/post';
export const getPosts = createServerFn({ method: 'GET' }).handler(async () =>
client.getContentList<Post>({ contentType: 'post' })
);
export const getPostBySlug = createServerFn({ method: 'GET' })
.validator((slug: string) => slug)
.handler(async ({ data: slug }) => {
const posts = await client.getContentList<Post>({ contentType: 'post', 'fields.slug': slug });
const post = posts.at(0); // undefined si ningún post tiene ese slug
return post ? { ...post, html: await marked.parse(post.content) } : null;
});
  • validator valida (y tipa) los datos que recibe la función. Todo lo que llega del navegador puede manipularse, así que valídalo. También puedes pasarle un esquema de una librería como Zod.
  • Lo que devuelve una server function se serializa y se envía al navegador, así que devuelve datos planos. Aquí el Markdown ya se convierte a HTML en el servidor.

Lista los posts

Llama a la server function desde el loader de la ruta y lee el resultado con Route.useLoaderData():

src/routes/index.tsx
import { createFileRoute, Link } from '@tanstack/react-router';
import { getPosts } from '../server/posts';
export const Route = createFileRoute('/')({
loader: () => getPosts(),
component: Home,
});
function Home() {
const posts = Route.useLoaderData();
return (
<main>
<h1>Mi blog</h1>
<ul>
{posts.map(post => (
<li key={post.id}>
<Link to="/posts/$slug" params={{ slug: post.slug }}>
{post.title}
</Link>
</li>
))}
</ul>
</main>
);
}

Ejecuta npm run dev y abre la URL que aparece en el terminal para ver tus posts.

Muestra un post

Crea una ruta dinámica. En TanStack Router, $slug en el nombre del fichero es un parámetro de ruta, disponible en los params del loader. Lanza notFound() cuando el post no exista:

// src/routes/posts.$slug.tsx
import { createFileRoute, Link, notFound } from '@tanstack/react-router';
import { getPostBySlug } from '../server/posts';
export const Route = createFileRoute('/posts/$slug')({
loader: async ({ params }) => {
const post = await getPostBySlug({ data: params.slug });
if (!post) {
throw notFound();
}
return post;
},
component: PostPage,
});
function PostPage() {
const post = Route.useLoaderData();
return (
<main>
<Link to="/">← Volver</Link>
<h1>{post.title}</h1>
<article dangerouslySetInnerHTML={{ __html: post.html }} />
</main>
);
}

Las server functions reciben sus argumentos en la propiedad data: getPostBySlug({ data: params.slug }).

Lo básico del servidor en TanStack Start

Llamar a una server function desde un componente

Las server functions no se limitan a los loaders. Puedes llamarlas desde un manejador de eventos, por ejemplo para un botón «Cargar más»:

src/server/posts.ts
const PAGE_SIZE = 5;
export const getPostsPage = createServerFn({ method: 'GET' })
.validator((page: number) => page)
.handler(async ({ data: page }) =>
client.getContentList<Post>({
contentType: 'post',
pagination: { take: PAGE_SIZE, skip: page * PAGE_SIZE },
})
);
src/components/load-more.tsx
import { useState } from 'react';
import { getPostsPage } from '../server/posts';
import type { Post } from '../models/post';
export function LoadMore() {
const [posts, setPosts] = useState<Post[]>([]);
const [page, setPage] = useState(0);
const handleClick = async () => {
const nextPosts = await getPostsPage({ data: page });
setPosts([...posts, ...nextPosts]);
setPage(page + 1);
};
return (
<>
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
<button onClick={handleClick}>Cargar más</button>
</>
);
}

Pinta <LoadMore /> donde quieras el botón, por ejemplo debajo del listado en src/routes/index.tsx.

Renderizado

TanStack Start renderiza cada página en el servidor en cada petición (SSR) y después la hidrata en el navegador; las navegaciones siguientes ocurren en cliente y solo piden datos a través de tus server functions. Los cambios de contenido se ven al momento, sin rebuild.

Si quieres servir todo el contenido desde la memoria del servidor y refrescarlo cuando publicas, consulta la guía de Dynamic Snapshots para TanStack Start.

Previsualiza contenido sin publicar

Mientras desarrollas, añade el prefijo PREVIEW_ al token para leer también el contenido que todavía no está publicado:

.env
CONTENT_ISLAND_ACCESS_TOKEN=PREVIEW_tu-token-de-acceso

Consulta Preview Token para más detalles.

Siguientes pasos