This guide demonstrates how to integrate external API calls into your TanStack Start application using route loaders. We will use TMDB API to fetch popular movies using TanStack Start and understand how to fetch data in a TanStack Start app.
The complete code for this tutorial is available on GitHub.
First, let's create a new TanStack Start project:
pnpx create-start-app movie-discovery
cd movie-discovery
pnpx create-start-app movie-discovery
cd movie-discovery
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
At this point, the project structure should look like this:
/movie-discovery
├── src/
│ ├── routes/
│ │ ├── __root.tsx # Root layout
│ │ ├── index.tsx # Home page
│ │ └── fetch-movies.tsx # Movie fetching route
│ ├── types/
│ │ └── movie.ts # Movie type definitions
│ ├── router.tsx # Router configuration
│ ├── routeTree.gen.ts # Generated route tree
│ └── styles.css # Global styles
├── public/ # Static assets
├── vite.config.ts # TanStack Start configuration
├── package.json # Project dependencies
└── tsconfig.json # TypeScript configuration
/movie-discovery
├── src/
│ ├── routes/
│ │ ├── __root.tsx # Root layout
│ │ ├── index.tsx # Home page
│ │ └── fetch-movies.tsx # Movie fetching route
│ ├── types/
│ │ └── movie.ts # Movie type definitions
│ ├── router.tsx # Router configuration
│ ├── routeTree.gen.ts # Generated route tree
│ └── styles.css # Global styles
├── public/ # Static assets
├── vite.config.ts # TanStack Start configuration
├── package.json # Project dependencies
└── tsconfig.json # TypeScript configuration
Once your project is set up, you can access your app at localhost:3000. You should see the default TanStack Start welcome page.
To fetch movies from the TMDB API, you need an authentication token. You can get this for free at themoviedb.org.
First, let's set up environment variables for our API key. Create a .env file in your project root:
touch .env
touch .env
Add your TMDB API token to this file:
TMDB_AUTH_TOKEN=your_bearer_token_here
TMDB_AUTH_TOKEN=your_bearer_token_here
Important: Make sure to add .env to your .gitignore file to keep your API keys secure.
Let's create TypeScript interfaces for our movie data. Create a new file at src/types/movie.ts:
// src/types/movie.ts
export interface Movie {
id: number
title: string
overview: string
poster_path: string | null
backdrop_path: string | null
release_date: string
vote_average: number
popularity: number
}
export interface TMDBResponse {
page: number
results: Movie[]
total_pages: number
total_results: number
}
// src/types/movie.ts
export interface Movie {
id: number
title: string
overview: string
poster_path: string | null
backdrop_path: string | null
release_date: string
vote_average: number
popularity: number
}
export interface TMDBResponse {
page: number
results: Movie[]
total_pages: number
total_results: number
}
Now let's create our route that fetches data from the TMDB API. Create a new file at src/routes/fetch-movies.tsx:
// src/routes/fetch-movies.tsx
import { createFileRoute } from '@tanstack/react-router'
import type { Movie, TMDBResponse } from '../types/movie'
const API_URL =
'https://api.themoviedb.org/3/discover/movie?include_adult=false&include_video=false&language=en-US&page=1&sort_by=popularity.desc'
async function fetchPopularMovies(): Promise<TMDBResponse> {
const token = process.env.TMDB_AUTH_TOKEN
if (!token) {
throw new Error('Missing TMDB_AUTH_TOKEN environment variable')
}
const response = await fetch(API_URL, {
headers: {
accept: 'application/json',
Authorization: `Bearer ${token}`,
},
})
if (!response.ok) {
throw new Error(`Failed to fetch movies: ${response.statusText}`)
}
const data = (await response.json()) as TMDBResponse
return data
}
export const Route = createFileRoute('/fetch-movies')({
component: MoviesPage,
loader: async (): Promise<{ movies: Movie[]; error: string | null }> => {
try {
const moviesData = await fetchPopularMovies()
return { movies: moviesData.results, error: null }
} catch (error) {
console.error('Error fetching movies:', error)
return { movies: [], error: 'Failed to load movies' }
}
},
})
// src/routes/fetch-movies.tsx
import { createFileRoute } from '@tanstack/react-router'
import type { Movie, TMDBResponse } from '../types/movie'
const API_URL =
'https://api.themoviedb.org/3/discover/movie?include_adult=false&include_video=false&language=en-US&page=1&sort_by=popularity.desc'
async function fetchPopularMovies(): Promise<TMDBResponse> {
const token = process.env.TMDB_AUTH_TOKEN
if (!token) {
throw new Error('Missing TMDB_AUTH_TOKEN environment variable')
}
const response = await fetch(API_URL, {
headers: {
accept: 'application/json',
Authorization: `Bearer ${token}`,
},
})
if (!response.ok) {
throw new Error(`Failed to fetch movies: ${response.statusText}`)
}
const data = (await response.json()) as TMDBResponse
return data
}
export const Route = createFileRoute('/fetch-movies')({
component: MoviesPage,
loader: async (): Promise<{ movies: Movie[]; error: string | null }> => {
try {
const moviesData = await fetchPopularMovies()
return { movies: moviesData.results, error: null }
} catch (error) {
console.error('Error fetching movies:', error)
return { movies: [], error: 'Failed to load movies' }
}
},
})
Now let's create the components that will display our movie data. Add these components to the same fetch-movies.tsx file:
// MovieCard component
const MovieCard = ({ movie }: { movie: Movie }) => {
return (
<div
className="bg-white/10 border border-white/20 rounded-lg overflow-hidden backdrop-blur-sm shadow-md hover:shadow-xl transition-all duration-300 hover:scale-105"
aria-label={`Movie: ${movie.title}`}
role="group"
>
{movie.poster_path && (
<img
src={`https://image.tmdb.org/t/p/w500${movie.poster_path}`}
alt={movie.title}
className="w-full h-64 object-cover"
/>
)}
<div className="p-4">
<MovieDetails movie={movie} />
</div>
</div>
)
}
// MovieDetails component
const MovieDetails = ({ movie }: { movie: Movie }) => {
return (
<>
<h3 className="text-lg font-semibold mb-2 line-clamp-2">{movie.title}</h3>
<p className="text-sm text-gray-300 mb-3 line-clamp-3 h-10">
{movie.overview}
</p>
<div className="flex justify-between items-center text-xs text-gray-400">
<span>{movie.release_date}</span>
<span className="flex items-center">
⭐️ {movie.vote_average.toFixed(1)}
</span>
</div>
</>
)
}
// MovieCard component
const MovieCard = ({ movie }: { movie: Movie }) => {
return (
<div
className="bg-white/10 border border-white/20 rounded-lg overflow-hidden backdrop-blur-sm shadow-md hover:shadow-xl transition-all duration-300 hover:scale-105"
aria-label={`Movie: ${movie.title}`}
role="group"
>
{movie.poster_path && (
<img
src={`https://image.tmdb.org/t/p/w500${movie.poster_path}`}
alt={movie.title}
className="w-full h-64 object-cover"
/>
)}
<div className="p-4">
<MovieDetails movie={movie} />
</div>
</div>
)
}
// MovieDetails component
const MovieDetails = ({ movie }: { movie: Movie }) => {
return (
<>
<h3 className="text-lg font-semibold mb-2 line-clamp-2">{movie.title}</h3>
<p className="text-sm text-gray-300 mb-3 line-clamp-3 h-10">
{movie.overview}
</p>
<div className="flex justify-between items-center text-xs text-gray-400">
<span>{movie.release_date}</span>
<span className="flex items-center">
⭐️ {movie.vote_average.toFixed(1)}
</span>
</div>
</>
)
}
Finally, let's create the main component that consumes the loader data:
// MoviesPage component
const MoviesPage = () => {
const { movies, error } = Route.useLoaderData()<{
movies: Movie[]
error: string | null
}>()
return (
<div
className="flex items-center justify-center min-h-screen p-4 text-white"
style={{
backgroundColor: '#000',
backgroundImage:
'radial-gradient(ellipse 60% 60% at 0% 100%, #444 0%, #222 60%, #000 100%)',
}}
role="main"
aria-label="Popular Movies Section"
>
<div className="w-full max-w-6xl p-8 rounded-xl backdrop-blur-md bg-black/50 shadow-xl border-8 border-black/10">
<h1 className="text-3xl mb-6 font-bold text-center">Popular Movies</h1>
{error && (
<div
className="text-red-400 text-center mb-4 p-4 bg-red-900/20 rounded-lg"
role="alert"
>
{error}
</div>
)}
{movies.length > 0 ? (
<div
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"
aria-label="Movie List"
>
{movies.slice(0, 12).map((movie) => (
<MovieCard key={movie.id} movie={movie} />
))}
</div>
) : (
!error && (
<div className="text-center text-gray-400" role="status">
Loading movies...
</div>
)
)}
</div>
</div>
)
}
// MoviesPage component
const MoviesPage = () => {
const { movies, error } = Route.useLoaderData()<{
movies: Movie[]
error: string | null
}>()
return (
<div
className="flex items-center justify-center min-h-screen p-4 text-white"
style={{
backgroundColor: '#000',
backgroundImage:
'radial-gradient(ellipse 60% 60% at 0% 100%, #444 0%, #222 60%, #000 100%)',
}}
role="main"
aria-label="Popular Movies Section"
>
<div className="w-full max-w-6xl p-8 rounded-xl backdrop-blur-md bg-black/50 shadow-xl border-8 border-black/10">
<h1 className="text-3xl mb-6 font-bold text-center">Popular Movies</h1>
{error && (
<div
className="text-red-400 text-center mb-4 p-4 bg-red-900/20 rounded-lg"
role="alert"
>
{error}
</div>
)}
{movies.length > 0 ? (
<div
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"
aria-label="Movie List"
>
{movies.slice(0, 12).map((movie) => (
<MovieCard key={movie.id} movie={movie} />
))}
</div>
) : (
!error && (
<div className="text-center text-gray-400" role="status">
Loading movies...
</div>
)
)}
</div>
</div>
)
}
Let's break down how the different parts of our application work together:
Now you can test your application by visiting http://localhost:3000/fetch-movies. If everything is set up correctly, you should see a grid of popular movies with their posters, titles, and ratings. Your app should look like this:
You've successfully built a movie discovery app that integrates with an external API using TanStack Start. This tutorial demonstrated how to use route loaders for server-side data fetching and building UI components with external data.
While fetching data at build time in TanStack Start is perfect for static content like blog posts or product pages, it's not ideal for interactive apps. If you need features like real-time updates, caching, or infinite scrolling, you'll want to use TanStack Query on the client side instead. TanStack Query makes it easy to handle dynamic data with built-in caching, background updates, and smooth user interactions. By using TanStack Start for static content and TanStack Query for interactive features, you get fast loading pages plus all the modern functionality users expect.
Your weekly dose of JavaScript news. Delivered every Monday to over 100,000 devs, for free.
Your weekly dose of JavaScript news. Delivered every Monday to over 100,000 devs, for free.