This tutorial will guide you through building a complete full-stack application using TanStack Start. You'll create a DevJokesapp where users can view and add developer-themed jokes, demonstrating key concepts of TanStack Start including server functions, file-based data storage, and React components.
Here's a demo of the app in action:
The complete code for this tutorial is available on GitHub.
First, let's create a new TanStack Start project:
pnpx create-start-app devjokes
cd devjokes
pnpx create-start-app devjokes
cd devjokes
When this script runs, it will ask you a few setup questions. You can either pick choices that work for you or just press enter to accept the defaults.
Optionally, you can pass in a --add-on flag to get options such as Shadcn, Clerk, Convex, TanStack Query, etc.
Once setup is complete, install dependencies and start the development server:
pnpm i
pnpm dev
pnpm i
pnpm dev
For this project, we'll need a few additional packages:
# Install uuid for generating unique IDs
pnpm add uuid
pnpm add -D @types/uuid
# Install uuid for generating unique IDs
pnpm add uuid
pnpm add -D @types/uuid
At this point, the project structure should look like this -
/devjokes
├── src/
│ ├── routes/
│ │ ├── __root.tsx # Root layout
│ │ ├── index.tsx # Home page
│ │ ├── demo.start.server-funcs.tsx # Demo server functions
│ │ └── demo.start.api-request.tsx # Demo API request
│ ├── api/ # API endpoints
│ ├── components/ # React components
│ ├── api.ts # API handler.
│ ├── client.tsx # Client entry point
│ ├── router.tsx # Router configuration
│ ├── routeTree.gen.ts # Generated route tree
│ ├── ssr.tsx # Server-side rendering
│ └── styles.css # Global styles
├── public/ # Static assets
├── app.config.ts # TanStack Start configuration
├── package.json # Project dependencies
└── tsconfig.json # TypeScript configuration
/devjokes
├── src/
│ ├── routes/
│ │ ├── __root.tsx # Root layout
│ │ ├── index.tsx # Home page
│ │ ├── demo.start.server-funcs.tsx # Demo server functions
│ │ └── demo.start.api-request.tsx # Demo API request
│ ├── api/ # API endpoints
│ ├── components/ # React components
│ ├── api.ts # API handler.
│ ├── client.tsx # Client entry point
│ ├── router.tsx # Router configuration
│ ├── routeTree.gen.ts # Generated route tree
│ ├── ssr.tsx # Server-side rendering
│ └── styles.css # Global styles
├── public/ # Static assets
├── app.config.ts # TanStack Start configuration
├── package.json # Project dependencies
└── tsconfig.json # TypeScript configuration
This structure might seem overwhelming at first, but here are the key files you need to focus on:
Once your project is set up, you can access your app at localhost:3000. You should see the default TanStack Start welcome page.
At this point, your app will look like this -
Let's start by creating a file-based storage system for our jokes.
Let's set up a list of jokes that we can use to render on the page. Create a data directory in your project root and a jokes.json file within it:
mkdir -p src/data
touch src/data/jokes.json
mkdir -p src/data
touch src/data/jokes.json
Now, let's add some sample jokes to this file:
[
{
"id": "1",
"question": "Why don't keyboards sleep?",
"answer": "Because they have two shifts"
},
{
"id": "2",
"question": "Are you a RESTful API?",
"answer": "Because you GET my attention, PUT some love, POST the cutest smile, and DELETE my bad day"
},
{
"id": "3",
"question": "I used to know a joke about Java",
"answer": "But I ran out of memory."
},
{
"id": "4",
"question": "Why do Front-End Developers eat lunch alone?",
"answer": "Because, they don't know how to join tables."
},
{
"id": "5",
"question": "I am declaring a war.",
"answer": "var war;"
}
]
[
{
"id": "1",
"question": "Why don't keyboards sleep?",
"answer": "Because they have two shifts"
},
{
"id": "2",
"question": "Are you a RESTful API?",
"answer": "Because you GET my attention, PUT some love, POST the cutest smile, and DELETE my bad day"
},
{
"id": "3",
"question": "I used to know a joke about Java",
"answer": "But I ran out of memory."
},
{
"id": "4",
"question": "Why do Front-End Developers eat lunch alone?",
"answer": "Because, they don't know how to join tables."
},
{
"id": "5",
"question": "I am declaring a war.",
"answer": "var war;"
}
]
Let's create a file to define our data types. Create a new file at src/types/index.ts:
// src/types/index.ts
export interface Joke {
id: string
question: string
answer: string
}
export type JokesData = Joke[]
// src/types/index.ts
export interface Joke {
id: string
question: string
answer: string
}
export type JokesData = Joke[]
Let's create a new file src/serverActions/jokesActions.ts to create a server function to perform a read-write operation. We will be creating a server function using createServerFn.
// src/serverActions/jokesActions.ts
import { createServerFn } from '@tanstack/react-start'
import * as fs from 'node:fs'
import type { JokesData } from '../types'
const JOKES_FILE = 'src/data/jokes.json'
export const getJokes = createServerFn({ method: 'GET' }).handler(async () => {
const jokes = await fs.promises.readFile(JOKES_FILE, 'utf-8')
return JSON.parse(jokes) as JokesData
})
// src/serverActions/jokesActions.ts
import { createServerFn } from '@tanstack/react-start'
import * as fs from 'node:fs'
import type { JokesData } from '../types'
const JOKES_FILE = 'src/data/jokes.json'
export const getJokes = createServerFn({ method: 'GET' }).handler(async () => {
const jokes = await fs.promises.readFile(JOKES_FILE, 'utf-8')
return JSON.parse(jokes) as JokesData
})
In this code, we are using createServerFn to create a server function that reads the jokes from the JSON file. The handler function is where we are using the fs module to read the file.
Now to consume this server function, we can simply call it in our code using TanStack Router which already comes with TanStack Start!
Now let's create a new component JokesList to render the jokes on the page with a little Tailwind styling sprinkle.
// src/components/JokesList.tsx
import { Joke } from '../types'
interface JokesListProps {
jokes: Joke[]
}
export function JokesList({ jokes }: JokesListProps) {
if (!jokes || jokes.length === 0) {
return <p className="text-gray-500 italic">No jokes found. Add some!</p>
}
return (
<div className="space-y-4">
<h2 className="text-xl font-semibold">Jokes Collection</h2>
{jokes.map((joke) => (
<div
key={joke.id}
className="bg-white p-4 rounded-lg shadow-md border border-gray-200"
>
<p className="font-bold text-lg mb-2">{joke.question}</p>
<p className="text-gray-700">{joke.answer}</p>
</div>
))}
</div>
)
}
// src/components/JokesList.tsx
import { Joke } from '../types'
interface JokesListProps {
jokes: Joke[]
}
export function JokesList({ jokes }: JokesListProps) {
if (!jokes || jokes.length === 0) {
return <p className="text-gray-500 italic">No jokes found. Add some!</p>
}
return (
<div className="space-y-4">
<h2 className="text-xl font-semibold">Jokes Collection</h2>
{jokes.map((joke) => (
<div
key={joke.id}
className="bg-white p-4 rounded-lg shadow-md border border-gray-200"
>
<p className="font-bold text-lg mb-2">{joke.question}</p>
<p className="text-gray-700">{joke.answer}</p>
</div>
))}
</div>
)
}
Now let's call our server function inside App.jsx using TanStack Router which already comes with TanStack Start!
// App.jsx
import { createFileRoute } from '@tanstack/react-router'
import { getJokes } from './serverActions/jokesActions'
import { JokesList } from './JokesList'
export const Route = createFileRoute('/')({
loader: async () => {
// Load jokes data when the route is accessed
return getJokes()
},
component: App,
})
const App = () => {
const jokes = Route.useLoaderData() || []
return (
<div className="p-4 flex flex-col">
<h1 className="text-2xl">DevJokes</h1>
<JokesList jokes={jokes} />
</div>
)
}
// App.jsx
import { createFileRoute } from '@tanstack/react-router'
import { getJokes } from './serverActions/jokesActions'
import { JokesList } from './JokesList'
export const Route = createFileRoute('/')({
loader: async () => {
// Load jokes data when the route is accessed
return getJokes()
},
component: App,
})
const App = () => {
const jokes = Route.useLoaderData() || []
return (
<div className="p-4 flex flex-col">
<h1 className="text-2xl">DevJokes</h1>
<JokesList jokes={jokes} />
</div>
)
}
When the page loads, jokes will have data from the jokes.json file already!
With a little Tailwind styling, the app should look like this:
So far, we have been able to read from the file successfully! We can use the same approach to write to the jokes.json file using createServerFunction.
It's time to modify the jokes.json file so that we can add new jokes to it. Let's create another server function but this time with a POST method to write to the same file.
// src/serverActions/jokesActions.ts
import { createServerFn } from '@tanstack/react-start'
import * as fs from 'node:fs'
import { v4 as uuidv4 } from 'uuid' // Add this import
import type { Joke, JokesData } from '../types'
export const addJoke = createServerFn({ method: 'POST' })
.validator((data: { question: string; answer: string }) => {
// Validate input data
if (!data.question || !data.question.trim()) {
throw new Error('Joke question is required')
}
if (!data.answer || !data.answer.trim()) {
throw new Error('Joke answer is required')
}
return data
})
.handler(async ({ data }) => {
try {
// Read the existing jokes from the file
const jokesData = await getJokes()
// Create a new joke with a unique ID
const newJoke: Joke = {
id: uuidv4(),
question: data.question,
answer: data.answer,
}
// Add the new joke to the list
const updatedJokes = [...jokesData, newJoke]
// Write the updated jokes back to the file
await fs.promises.writeFile(
JOKES_FILE,
JSON.stringify(updatedJokes, null, 2),
'utf-8',
)
return newJoke
} catch (error) {
console.error('Failed to add joke:', error)
throw new Error('Failed to add joke')
}
})
// src/serverActions/jokesActions.ts
import { createServerFn } from '@tanstack/react-start'
import * as fs from 'node:fs'
import { v4 as uuidv4 } from 'uuid' // Add this import
import type { Joke, JokesData } from '../types'
export const addJoke = createServerFn({ method: 'POST' })
.validator((data: { question: string; answer: string }) => {
// Validate input data
if (!data.question || !data.question.trim()) {
throw new Error('Joke question is required')
}
if (!data.answer || !data.answer.trim()) {
throw new Error('Joke answer is required')
}
return data
})
.handler(async ({ data }) => {
try {
// Read the existing jokes from the file
const jokesData = await getJokes()
// Create a new joke with a unique ID
const newJoke: Joke = {
id: uuidv4(),
question: data.question,
answer: data.answer,
}
// Add the new joke to the list
const updatedJokes = [...jokesData, newJoke]
// Write the updated jokes back to the file
await fs.promises.writeFile(
JOKES_FILE,
JSON.stringify(updatedJokes, null, 2),
'utf-8',
)
return newJoke
} catch (error) {
console.error('Failed to add joke:', error)
throw new Error('Failed to add joke')
}
})
In this code:
Now, let's modify our home page to display jokes and provide a form to add new ones. Let's create a new component called JokeForm.jsx and add the following form to it:
// src/components/JokeForm.tsx
import { useState } from 'react'
import { useRouter } from '@tanstack/react-router'
import { addJoke } from '../serverActions/jokesActions'
export function JokeForm() {
const router = useRouter()
const [question, setQuestion] = useState('')
const [answer, setAnswer] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
return (
<form onSubmit={handleSubmit} className="flex flex-row gap-2 mb-6">
{error && (
<div className="bg-red-100 text-red-700 p-2 rounded mb-4">{error}</div>
)}
<div className="flex flex-col sm:flex-row gap-4 mb-8">
<input
id="question"
type="text"
placeholder="Enter joke question"
className="w-full p-2 border rounded focus:ring focus:ring-blue-300 flex-1"
value={question}
onChange={(e) => setQuestion(e.target.value)}
required
/>
<input
id="answer"
type="text"
placeholder="Enter joke answer"
className="w-full p-2 border rounded focus:ring focus:ring-blue-300 flex-1 py-4"
value={answer}
onChange={(e) => setAnswer(e.target.value)}
required
/>
<button
type="submit"
disabled={isSubmitting}
className="bg-blue-500 hover:bg-blue-600 text-white font-medium rounded disabled:opacity-50 px-4"
>
{isSubmitting ? 'Adding...' : 'Add Joke'}
</button>
</div>
</form>
)
}
// src/components/JokeForm.tsx
import { useState } from 'react'
import { useRouter } from '@tanstack/react-router'
import { addJoke } from '../serverActions/jokesActions'
export function JokeForm() {
const router = useRouter()
const [question, setQuestion] = useState('')
const [answer, setAnswer] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
return (
<form onSubmit={handleSubmit} className="flex flex-row gap-2 mb-6">
{error && (
<div className="bg-red-100 text-red-700 p-2 rounded mb-4">{error}</div>
)}
<div className="flex flex-col sm:flex-row gap-4 mb-8">
<input
id="question"
type="text"
placeholder="Enter joke question"
className="w-full p-2 border rounded focus:ring focus:ring-blue-300 flex-1"
value={question}
onChange={(e) => setQuestion(e.target.value)}
required
/>
<input
id="answer"
type="text"
placeholder="Enter joke answer"
className="w-full p-2 border rounded focus:ring focus:ring-blue-300 flex-1 py-4"
value={answer}
onChange={(e) => setAnswer(e.target.value)}
required
/>
<button
type="submit"
disabled={isSubmitting}
className="bg-blue-500 hover:bg-blue-600 text-white font-medium rounded disabled:opacity-50 px-4"
>
{isSubmitting ? 'Adding...' : 'Add Joke'}
</button>
</div>
</form>
)
}
Now, let's wire the form up to our addJoke server function in the handleSubmit function. Calling a server action is simple! It's just a function call.
//JokeForm.tsx
import { useState } from 'react'
import { addJoke } from '../serverActions/jokesActions'
import { useRouter } from '@tanstack/react-router'
export function JokeForm() {
const router = useRouter()
const [question, setQuestion] = useState('')
const [answer, setAnswer] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSubmit = async () => {
if (!question || !answer || isSubmitting) return
try {
setIsSubmitting(true)
await addJoke({
data: { question, answer },
})
// Clear form
setQuestion('')
setAnswer('')
// Refresh data
router.invalidate()
} catch (error) {
console.error('Failed to add joke:', error)
setError('Failed to add joke')
} finally {
setIsSubmitting(false)
}
}
return (
<form onSubmit={handleSubmit} className="flex flex-row gap-2 mb-6">
{error && (
<div className="bg-red-100 text-red-700 p-2 rounded mb-4">{error}</div>
)}
<input
type="text"
name="question"
placeholder="Question"
className="p-1 border rounded w-full"
required
onChange={(e) => setQuestion(e.target.value)}
value={question}
/>
<input
type="text"
name="answer"
placeholder="Answer"
className="p-1 border rounded w-full"
required
onChange={(e) => setAnswer(e.target.value)}
value={answer}
/>
<button
className="bg-blue-500 text-white p-1 rounded hover:bg-blue-600"
disabled={isSubmitting}
>
{isSubmitting ? 'Adding...' : 'Add Joke'}
</button>
</form>
)
}
//JokeForm.tsx
import { useState } from 'react'
import { addJoke } from '../serverActions/jokesActions'
import { useRouter } from '@tanstack/react-router'
export function JokeForm() {
const router = useRouter()
const [question, setQuestion] = useState('')
const [answer, setAnswer] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSubmit = async () => {
if (!question || !answer || isSubmitting) return
try {
setIsSubmitting(true)
await addJoke({
data: { question, answer },
})
// Clear form
setQuestion('')
setAnswer('')
// Refresh data
router.invalidate()
} catch (error) {
console.error('Failed to add joke:', error)
setError('Failed to add joke')
} finally {
setIsSubmitting(false)
}
}
return (
<form onSubmit={handleSubmit} className="flex flex-row gap-2 mb-6">
{error && (
<div className="bg-red-100 text-red-700 p-2 rounded mb-4">{error}</div>
)}
<input
type="text"
name="question"
placeholder="Question"
className="p-1 border rounded w-full"
required
onChange={(e) => setQuestion(e.target.value)}
value={question}
/>
<input
type="text"
name="answer"
placeholder="Answer"
className="p-1 border rounded w-full"
required
onChange={(e) => setAnswer(e.target.value)}
value={answer}
/>
<button
className="bg-blue-500 text-white p-1 rounded hover:bg-blue-600"
disabled={isSubmitting}
>
{isSubmitting ? 'Adding...' : 'Add Joke'}
</button>
</form>
)
}
With this, our UI should look like this:
Let's break down how the different parts of our application work together:
Server Functions: These run on the server and handle data operations
TanStack Router: Handles routing and data loading
React Components: Build the UI of our application
File-Based Storage: Stores our jokes in a JSON file
When a user visits the home page:
When a user adds a new joke:
Here's a demo of the app in action:
Here are some common issues you might encounter when building your TanStack Start application and how to resolve them:
If your server functions aren't working as expected:
If route data isn't loading properly:
If form submissions aren't working:
When working with file-based storage:
Congratulations! You've built a full-stack DevJokes app using TanStack Start. In this tutorial, you've learned:
This simple application demonstrates the power of TanStack Start for building full-stack applications with a minimal amount of code. You can extend this app by adding features like:
The complete code for this tutorial is available on GitHub.
Your weekly dose of JavaScript news. Delivered every Monday to over 100,000 devs, for free.