Get started with TanStack Start
In this guide you will build a small blog with TanStack Start (React) that reads its posts from Content Island:
- A home page that lists every post.
- A detail page per post, at
/posts/<slug>, that renders its Markdown content.
Along the way you will see the basics you need in any TanStack Start project: server functions, route loaders and how they work together to keep the access token on the server.
Prerequisites
- Node.js 20 or higher.
- A Content Island account.
Prepare your content in Content Island
-
Create a new project from the Projects page.
-
In the Model tab, create a model called
postwith these fields:Field name Type Notes titleShort Text Required. slugShort Text Required and unique. contentLong Text Markdown body of the post. -
In the Content tab, add two or three posts and publish them.
-
In the General tab, copy the project’s Token (the API access token).
Create the TanStack Start project
-
Create a new project:
Terminal window npx @tanstack/cli create my-blogcd my-blog -
Install the Content Island client and
marked(to render Markdown):Terminal window npm install @content-island/api-client marked
Configure the access token
The access token gives access to your project, so it must never reach the browser. Store it in an environment variable:
CONTENT_ISLAND_ACCESS_TOKEN=your-access-tokenTanStack Start loads the .env file into process.env for you, so the token is available in your server code without extra packages. In production, set the same variable in your hosting provider.
Create the client
Create the client once and reuse it from your server functions:
import { createClient } from '@content-island/api-client';
export const client = createClient({ accessToken: process.env.CONTENT_ISLAND_ACCESS_TOKEN!,});And a type that mirrors the post model:
export interface Post { id: string; title: string; slug: string; content: string;}Server functions and loaders
Before writing pages, it is worth understanding the two pieces you will use to load data:
Route loader | Server function (createServerFn) | |
|---|---|---|
| Where does it run? | On the server for the first request, in the browser on client-side navigation. | Always on the server. |
| Can it use the token? | No (its code also ships to the browser). | Yes. |
| How is it called? | Automatically by the router before rendering the route. | Like a normal async function, from anywhere. From the browser it becomes a fetch to your server. |
So the pattern is: read Content Island inside server functions, and call those server functions from your loaders.
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 when no post has that slug return post ? { ...post, html: await marked.parse(post.content) } : null; });validatorvalidates (and types) the data the function receives. Anything coming from the browser is untrusted, so validate it. You can also pass a schema from a library like Zod.- What a server function returns is serialized and sent to the browser, so return plain data. Here the Markdown is already converted to HTML on the server.
List the posts
Call the server function from the route loader and read the result with Route.useLoaderData():
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>My blog</h1> <ul> {posts.map(post => ( <li key={post.id}> <Link to="/posts/$slug" params={{ slug: post.slug }}> {post.title} </Link> </li> ))} </ul> </main> );}Run npm run dev and open the URL shown in the terminal to see your posts.
Show a post
Create a dynamic route. In TanStack Router, $slug in the file name is a path parameter, available in the loader’s params. Throw notFound() when the post doesn’t exist:
// src/routes/posts.$slug.tsximport { 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="/">← Back</Link> <h1>{post.title}</h1> <article dangerouslySetInnerHTML={{ __html: post.html }} /> </main> );}Server functions receive their arguments in a data property: getPostBySlug({ data: params.slug }).
Server basics in TanStack Start
Calling a server function from a component
Server functions are not limited to loaders. You can call them from an event handler, for example for a “Load more” button:
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 }, }) );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}>Load more</button> </> );}Render <LoadMore /> wherever you want the button, for example under the list in src/routes/index.tsx.
Rendering
TanStack Start renders every page on the server on each request (SSR) and then hydrates it in the browser; later navigations happen on the client and only fetch data through your server functions. Content changes are visible immediately, without rebuilding.
If you want to serve all the content from the server’s memory and refresh it when you publish, check the Dynamic Snapshots for TanStack Start guide.
Preview unpublished content
While you develop, prefix the token with PREVIEW_ to also read content that is not published yet:
CONTENT_ISLAND_ACCESS_TOKEN=PREVIEW_your-access-tokenSee Preview Token for more details.
Next steps
- Deploy your app: Vercel, Netlify or Cloudflare Pages.
- Serve content from memory and refresh it on publish with Dynamic Snapshots.
- Serve several languages with Multilingual Support.
- Explore every method of the Client API library.