If you've been learning React and TypeScript, you might have heard about TanStack Start. It's a modern framework that helps you build full-stack applications without the headache of managing separate frontend and backend projects. In this guide, I'll walk you through building a simple to-do app that covers all the basics: creating, reading, updating, and deleting data (we call this CRUD for short).
What Makes TanStack Start Different?
Think of TanStack Start as your friendly assistant that handles the complicated stuff for you. Instead of building a separate API server and dealing with all the networking code, you can write simple TypeScript functions on the server and call them directly from your React components. Magic? Not quite—it's just really smart automation.
Here's what makes it special:
- Type Safety: If you make a mistake, TypeScript catches it before your app breaks
- No API Boilerplate: No need to write fetch requests or handle CORS errors
- Server and Client Together: Everything lives in one project
- Built on Proven Tools: Uses React, TanStack Router, and TanStack Query under the hood
What We're Building
We're creating a simple to-do list app called "Todo-Stack" where users can:
- See all their to-dos (Read)
- Add new to-dos (Create)
- Mark to-dos as complete (Update)
- Delete to-dos they don't need anymore (Delete)
Let's break this down into simple steps.
Step 1: Setting Up Your Project
First, let's create a new TanStack Start project. Open your terminal and run:
npm create @tanstack/start@latest
You'll be prompted to name your project (e.g., "tanstack-todo-app") and choose options:
- Framework: React
- Language: TypeScript
- Router: File-Based Router
Then navigate into your project and start the development server:
cd your-project-name
npm install
npm run dev
Your app should now be running at http://localhost:3000. If you see a welcome page, you're all set!
Step 2: Understanding the Project Structure
Before we dive into coding, let's understand what we're working with:
src/
routes/ # Your pages go here
server/ # Server-side logic
types.ts # Shared data types
This structure keeps your code organized. Routes are your pages, server holds your backend logic, and types ensure everything uses the same data format.
Step 3: Define Your Data Structure
Let's create a simple type definition for our to-dos. Create a file called src/types.ts:
export interface Todo {
id: string;
title: string;
completed: boolean;
}
This tells TypeScript exactly what a to-do looks like: it has an id, a title, and a completed status. Simple!
Step 4: Create Server Functions (Your Backend)
Now for the fun part. Create a file src/server/todos.ts. This is where all your backend logic lives.
Setting Up In-Memory Storage
import { createServerFn } from "@tanstack/react-start";
import type { Todo } from "../types";
// This is our "database" (just an array for learning)
let todoDatabase: Todo[] = [];
For learning purposes, we're using a simple array. In a real app, you'd use a database like PostgreSQL or MongoDB.
Reading To-Dos
export const getTodos = createServerFn().handler(async () => {
// This code runs only on the server
return todoDatabase;
});
That's it! This function fetches all to-dos. The createServerFn() automatically handles turning this into a secure API endpoint.
Creating To-Dos
export const createTodo = createServerFn({ method: "POST" })
.inputValidator((data: { title: string }) => data)
.handler(async ({ data }) => {
const newTodo: Todo = {
id: String(Date.now()),
title: data.title,
completed: false,
};
todoDatabase.push(newTodo);
return newTodo;
});
Here we're doing a few things:
- Specifying this is a
POSTrequest (for creating data) - Validating that incoming data has a
titlefield - Creating a new to-do with a unique ID
- Adding it to our database
Updating and Deleting
The same pattern works for updating and deleting:
export const updateTodo = createServerFn({ method: "PATCH" })
.inputValidator((data: { id: string; completed: boolean }) => data)
.handler(async ({ data }) => {
const todo = todoDatabase.find((t) => t.id === data.id);
if (todo) {
todo.completed = data.completed;
}
return todo;
});
export const deleteTodo = createServerFn({ method: "DELETE" })
.inputValidator((data: { id: string }) => data)
.handler(async ({ data }) => {
todoDatabase = todoDatabase.filter((t) => t.id !== data.id);
return { success: true };
});
Step 5: Display To-Dos (The Frontend)
Now let's show these to-dos to users. Open src/routes/index.tsx:
import { createFileRoute } from "@tanstack/react-router";
import { getTodos } from "../server/todos";
export const Route = createFileRoute("/")({
// This runs before the page loads
loader: async () => {
const todos = await getTodos();
return { todos };
},
// This is your React component
component: function Home() {
const { todos } = Route.useLoaderData();
return (
<div>
<h1>My To-Do List</h1>
<ul>
{todos.map((todo) => (
<li key={todo.id}>
{todo.title} {todo.completed ? "✓" : "○"}
</li>
))}
</ul>
</div>
);
},
});
What's happening here?
- The
loaderfunction fetches data before the page renders - We call
getTodos()directly—no fetch, no API URLs, no pain - The component uses
Route.useLoaderData()to access the loaded data - We map over the todos and display them
Step 6: Adding New To-Dos
Let's add a form to create new to-dos:
import { useRouter } from "@tanstack/react-router";
import { createTodo } from "../server/todos";
import { useState } from "react";
function TodoForm() {
const [title, setTitle] = useState("");
const router = useRouter();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Call the server function
await createTodo({ data: { title } });
// Refresh the list to show the new to-do
await router.invalidate({ sync: true });
// Clear the input
setTitle("");
};
return (
<form onSubmit={handleSubmit}>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="What needs to be done?"
/>
<button type="submit">Add</button>
</form>
);
}
The key line here is router.invalidate({ sync: true }). This tells the app: "Hey, the data changed! Go fetch it again." Without this, your new to-do wouldn't appear until you refresh the page.
Step 7: The Important Plugin Order
Here's something technical but crucial. Open your vite.config.ts file. The order of plugins matters:
import { defineConfig } from "vite";
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
import viteReact from "@vitejs/plugin-react";
export default defineConfig({
plugins: [
tanstackStart(), // Must come FIRST
viteReact(), // Then React
],
});
Why? The TanStack Start plugin needs to run first to properly handle server functions and routing. The React plugin then handles the React-specific transformations. This order ensures your server functions and client code are processed correctly.
Step 8: Deploying to Netlify
Once your app works locally, let's deploy it! Netlify makes this super easy.
Install the Netlify Plugin
npm install -D @netlify/vite-plugin-tanstack-start
Update Your Vite Config
import netlify from "@netlify/vite-plugin-tanstack-start";
export default defineConfig({
plugins: [
tanstackStart(),
netlify(), // Add this
viteReact(),
],
});
Create a netlify.toml File
In your project root, create netlify.toml:
[build]
command = "vite build"
publish = "dist/client"
[dev]open
command = "vite dev"
port = 3000
This tells Netlify how to build your app, where to find the static files, and how to run it locally.
Deploy
- Push your code to GitHub
- Connect your repo to Netlify
- Netlify will automatically build and deploy
That's it! Your app is now live on the internet.
Key Takeaways
Let's recap what you've learned:
- Server Functions let you write backend code that's automatically turned into API endpoints
- Type Safety catches errors before they reach production
- Loader Functions fetch data before pages render
- Router Invalidation keeps your UI in sync with server data
- Plugin Order matters in your build configuration
- Deployment is simple with the right plugins
Common Mistakes to Avoid
- Forgetting to invalidate: After mutations, always call
router.invalidate()or your UI won't update - Wrong plugin order:
tanstackStart()must come beforeviteReact() - Not using TypeScript: You'll lose type safety and make debugging harder
- Skipping validation: Always validate input data on the server
What's Next?
Now that you understand the basics, try these exercises:
- Add a filter to show only completed or incomplete to-dos
- Add due dates to your to-dos
- Style your app with Tailwind CSS
- Connect a real database like Supabase or Planetscale
The beautiful thing about TanStack Start is that it gets out of your way. You write normal TypeScript functions, and the framework handles the networking, caching, and type safety. No more wrestling with API contracts or managing complex state.
If you're building your next project and want to move fast without sacrificing code quality, give TanStack Start a try. You might be surprised how much simpler full-stack development can be.
Want help building your next full-stack application? I work with startups and teams to build scalable React and TypeScript applications. Book a consultation to discuss your project.
Happy coding! 🚀