TanStack Start 中文文档
教程

用 TanStack Start 调用外部 API

本指南演示如何用路由加载器把外部 API 调用集成到你的 TanStack Start 应用中。我们将用 TMDB API 获取热门电影,并理解在 TanStack Start 应用中如何获取数据。

本教程的完整代码见 GitHub

你将学到

  1. 在 TanStack Start 中搭建外部 API 集成
  2. 实现用于服务端数据获取的路由加载器
  3. 用获取到的数据构建响应式 UI 组件
  4. 处理加载状态和错误管理

前置条件

  • React 和 TypeScript 的基础知识
  • 你的机器上安装了 Node.js(v18+)和 pnpm
  • 一个 TMDB API 密钥(在 themoviedb.org 免费获取)

最好先了解

搭建 TanStack Start 项目

首先,创建一个新的 TanStack Start 项目:

pnpx create-start-app movie-discovery
cd movie-discovery

脚本运行时,会问你几个设置问题。你可以选择适合你的选项,也可以直接按回车接受默认值。

可选地,你可以传入 --add-on 标志,获得 Shadcn、Clerk、Convex、TanStack Query 等选项。

设置完成后,安装依赖并启动开发服务器:

pnpm i
pnpm dev

理解项目结构

此时,项目结构应该看起来像这样:

/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 or rsbuild.config.ts    # TanStack Start configuration
├── package.json                          # Project dependencies
└── tsconfig.json                         # TypeScript configuration

项目设置好后,你可以在 localhost:3000 访问你的应用。你应该会看到默认的 TanStack Start 欢迎页。

第 1 步:创建一个带 TMDB_AUTH_TOKEN 的 .env 文件

要从 TMDB API 获取电影,你需要一个认证令牌。你可以在 themoviedb.org 免费获取。

首先,为我们的 API 密钥设置环境变量。在项目根目录创建一个 .env 文件:

touch .env

把你的 TMDB API 令牌添加到这个文件:

TMDB_AUTH_TOKEN=your_bearer_token_here

重要:确保把 .env 添加到你的 .gitignore 文件中,以保证你的 API 密钥安全。

第 2 步:定义数据类型

让我们为电影数据创建 TypeScript 接口。在 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
}

第 3 步:创建带 API 获取函数的路由

要调用 TMDB API,我们将创建一个在服务器上获取数据的服务器函数。这种方式让我们的 API 凭据保持安全,永远不会暴露给客户端。 让我们创建从 TMDB API 获取数据的路由。在 src/routes/fetch-movies.tsx 新建一个文件:

// src/routes/fetch-movies.tsx
import { createFileRoute } from '@tanstack/react-router'
import type { Movie, TMDBResponse } from '../types/movie'
import { createServerFn } from '@tanstack/react-start'

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'

const fetchPopularMovies = createServerFn().handler(
  async (): Promise<TMDBResponse> => {
    const response = await fetch(API_URL, {
      headers: {
        accept: 'application/json',
        Authorization: `Bearer ${process.env.TMDB_AUTH_TOKEN}`,
      },
    })

    if (!response.ok) {
      throw new Error(`Failed to fetch movies: ${response.statusText}`)
    }

    return response.json()
  },
)

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' }
    }
  },
})

这里发生了什么:

  • createServerFn() 创建了一个仅在服务器上运行的函数,确保我们的 TMDB_AUTH_TOKEN 环境变量永远不会暴露给客户端。服务器函数向 TMDB API 发起带认证的请求,并返回解析后的 JSON 响应。
  • 当用户访问 /fetch-movies 时,路由加载器在服务器上运行,在页面渲染之前调用我们的服务器函数
  • 错误处理确保组件始终收到有效的数据结构——要么是电影列表,要么是一个空数组加错误信息
  • 这种模式开箱即用地提供了服务端渲染、自动类型安全和安全地处理 API 凭据的能力。

译者注:为什么用服务器函数而不是直接在 loader 里 fetch?

关键原因是保护 API 凭据TMDB_AUTH_TOKEN 是仅服务端的环境变量。如果直接在路由的 loader 里 fetch(加载器是同构的,会同时打进客户端和服务端包),令牌可能被编译进客户端打包产物。把请求包进 createServerFn(),客户端拿到的只是一个 RPC 桩,真实的网络请求只发生在服务器上。这就是「服务器函数是 API 凭据的安全边界」的典型例子。

第 4 步:构建电影组件

现在让我们创建展示电影数据的组件。把这些组件添加到同一个 fetch-movies.tsx 文件中:

// 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>
    </>
  )
}

第 5 步:创建 MoviesPage 组件

最后,让我们创建消费加载器数据的主组件:

// MoviesPage component
const MoviesPage = () => {
  const { movies, error } = Route.useLoaderData()

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

理解各部分如何协同工作

sequenceDiagram
    autonumber
    actor U as User
    participant R as Router (TanStack Start)
    participant L as Route Loader (/fetch-movies)
    participant A as External API (TMDB)
    participant V as MoviesPage (UI)

    U->>R: Navigate to /fetch-movies
    R->>L: Invoke loader (server-side)
    L->>A: GET /movie/popular\nAuthorization: Bearer <TOKEN>
    A-->>L: JSON TMDBResponse
    alt response.ok
        L-->>R: { movies, error: null }
        R->>V: Render SSR with movies
        V-->>U: HTML with movie grid
    else non-ok / error
        L-->>R: { movies: [], error: "Failed to load movies" }
        R->>V: Render SSR with error alert
        V-->>U: HTML with error state
    end

    note over L,V: Loader validates response.ok,\nreturns data or error for initial render

让我们拆解应用的不同部分是如何协同工作的:

  1. 路由加载器:当用户访问 /fetch-movies 时,加载器函数在服务器上运行
  2. API 调用:加载器调用 fetchPopularMovies(),它向 TMDB 发起 HTTP 请求
  3. 服务端渲染:数据在服务器上获取,减轻客户端的负载
  4. 组件渲染:MoviesPage 组件通过 Route.useLoaderData() 接收数据
  5. 渲染 UI:用获取到的数据渲染电影卡片

第 6 步:测试你的应用

现在你可以访问 http://localhost:3000/fetch-movies 来测试你的应用。如果一切设置正确,你应该会看到一个热门电影网格,带有海报、标题和评分。你的应用应该看起来像这样:

Netflix style movie setup

总结

你已经成功构建了一个用 TanStack Start 集成外部 API 的电影发现应用。本教程演示了如何用路由加载器做服务端数据获取,以及如何用外部数据构建 UI 组件。

虽然在 TanStack Start 中于构建时获取数据非常适合博客文章或产品页面这类静态内容,但它并不适合交互式应用。如果你需要实时更新、缓存或无限滚动等功能,你应该在客户端使用 TanStack Query。TanStack Query 让处理动态数据变得简单,内置缓存、后台更新和流畅的用户交互。用 TanStack Start 处理静态内容、用 TanStack Query 处理交互特性,你既能获得快速的页面加载,又能拥有用户期望的所有现代功能。

On this page