Back to Blog

Building Your First Full-Stack App with TanStack Start: A Beginner's Guide

Adeel Imran
Adeel Imran

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:

  1. Specifying this is a POST request (for creating data)
  2. Validating that incoming data has a title field
  3. Creating a new to-do with a unique ID
  4. 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?

  1. The loader function fetches data before the page renders
  2. We call getTodos() directly—no fetch, no API URLs, no pain
  3. The component uses Route.useLoaderData() to access the loaded data
  4. 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

  1. Push your code to GitHub
  2. Connect your repo to Netlify
  3. 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:

  1. Server Functions let you write backend code that's automatically turned into API endpoints
  2. Type Safety catches errors before they reach production
  3. Loader Functions fetch data before pages render
  4. Router Invalidation keeps your UI in sync with server data
  5. Plugin Order matters in your build configuration
  6. 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 before viteReact()
  • 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! 🚀