Skip to content

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

Prepare your content in Content Island

  1. Create a new project from the Projects page.

  2. In the Model tab, create a model called post with these fields:

    Field nameTypeNotes
    titleShort TextRequired.
    slugShort TextRequired and unique.
    contentLong TextMarkdown body of the post.
  3. In the Content tab, add two or three posts and publish them.

  4. In the General tab, copy the project’s Token (the API access token).

Create the TanStack Start project

  1. Create a new project:

    Terminal window
    npx @tanstack/cli create my-blog
    cd my-blog
  2. 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:

.env
CONTENT_ISLAND_ACCESS_TOKEN=your-access-token

TanStack 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:

src/server/content-island.ts
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:

src/models/post.ts
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 loaderServer 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.

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 when no post has that slug
return post ? { ...post, html: await marked.parse(post.content) } : null;
});
  • validator validates (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():

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>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.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="/">← 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:

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}>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:

.env
CONTENT_ISLAND_ACCESS_TOKEN=PREVIEW_your-access-token

See Preview Token for more details.

Next steps