---
title: "Data Fetching"
description: "Fetch data from the server route using Nuxt's useFetch composable, handle loading states, and compare the approach to React Server Components and client-side fetching."
canonical_url: "https://vercel.com/academy/nuxt-on-vercel/data-fetching"
md_url: "https://vercel.com/academy/nuxt-on-vercel/data-fetching.md"
docset_id: "vercel-academy"
doc_version: "1.0"
last_updated: "2026-05-02T08:59:56.652Z"
content_type: "lesson"
course: "nuxt-on-vercel"
course_title: "Nuxt on Vercel"
prerequisites:  []
---

<agent-instructions>
Vercel Academy — structured learning, not reference docs.
Lessons are sequenced.
Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume.
The lesson shows one path; if the human's project diverges, adapt concepts to their setup.
Preserve the learning goal over literal steps.
Quizzes are pedagogical — engage, don't spoil.
Quiz answers are included for your reference.
</agent-instructions>

# Data Fetching

# Data Fetching

In Next.js, data fetching splits into two worlds. Server Components fetch data with plain `async/await` at the top of the component. Client Components use libraries like SWR or TanStack Query, or roll their own `useEffect` + `useState` pattern. You choose your world when you choose your component type.

Nuxt gives you one composable that works everywhere: `useFetch`. Call it in a page or component, and Nuxt figures out the rest. On the server during SSR, it fetches the data and serializes it into the HTML payload. On the client during navigation, it fetches from the API. You write the call once and it does the right thing in both contexts. No "use client" directive, no separate data layer.

Let's wire up the browse page so those 17 hot springs actually show up.

## Outcome

Load and display hot springs on the browse page using `useFetch`.

## Fast Track

1. Add `useFetch("/api/springs")` to the browse page
2. Loop over the results with `v-for` and render `SpringCard` for each
3. Add a loading state

## Hands-on exercise 2.2

Connect the browse page to the server route and render the springs data.

**Requirements:**

1. Use `useFetch` to load data from `/api/springs` in `app/pages/springs/index.vue`
2. Display a loading message while data is being fetched
3. Render a `SpringCard` for each spring using `v-for`
4. Show an empty state when no springs are returned
5. Display the count of springs found

**Implementation hints:**

- `useFetch` returns `{ data, status, error }`. `status` can be `"idle"`, `"pending"`, `"success"`, or `"error"`
- `v-for` is Vue's loop directive. It replaces `.map()` in JSX: `<div v-for="item in items" :key="item.id">`
- `v-if` / `v-else-if` / `v-else` handle conditional rendering. They replace ternaries and `&&` in JSX
- `SpringCard` is auto-imported from `app/components/`. Pass the spring with `:spring="spring"`

Here's the React version you're probably used to:

```tsx
// Next.js client component — for comparison
"use client";
import { useEffect, useState } from "react";

export default function SpringsPage() {
  const [springs, setSprings] = useState([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetch("/api/springs")
      .then((res) => res.json())
      .then(setSprings)
      .finally(() => setLoading(false));
  }, []);

  if (loading) return <p>Loading...</p>;
  return springs.map((s) => <SpringCard key={s.id} spring={s} />);
}
```

Seven imports, a state variable, an effect, a cleanup concern. Now the Nuxt version:

```vue title="app/pages/springs/index.vue"
<script setup lang="ts">
const { data: springs, status } = await useFetch("/api/springs");
</script>
```

One line. `useFetch` handles the request, caching, SSR serialization, and reactivity. The `data` ref updates when the response arrives. `status` tells you where the request is in its lifecycle.

The template uses `v-if` and `v-for` to handle the three states: loading, results, and empty:

```vue title="app/pages/springs/index.vue"
<template>
  <div>
    <div>
      <h1>
        Browse Hot Springs
      </h1>
      <p>
        {{ springs?.length ?? 0 }} springs found
      </p>
    </div>

    <!-- Loading state -->
    <div v-if="status === 'pending'">
      Loading springs...
    </div>

    <!-- Results -->
    <div v-else-if="springs?.length">
      <SpringCard v-for="spring in springs" :key="spring.id" :spring="spring" />
    </div>

    <!-- Empty state -->
    <div v-else>
      No springs match your filters. Try broadening your search.
    </div>
  </div>
</template>
```

A few things that will feel different from React:

`v-for="spring in springs"` replaces `springs.map((spring) => ...)`. The `:key` goes on the same element as the `v-for`, not on a wrapper. The right side of `v-for` is a regular JavaScript expression, so you can chain `.filter()`, `.slice()`, or anything else inline. For non-trivial transformations, prefer a `computed` — Vue caches the result and only recomputes when dependencies change.

`v-if` / `v-else-if` / `v-else` must be on adjacent sibling elements. If you put a `<div>` between `v-if` and `v-else`, Vue won't connect them. This catches people coming from JSX where ternaries can span any distance.

The `{{ springs?.length ?? 0 }}` expression works because Vue template expressions support most JavaScript. Optional chaining, nullish coalescing, ternaries, method calls. What they don't support: statements. No `if`, no `for`, no variable declarations inside `{{ }}`.

\*\*Note: useFetch vs $fetch\*\*

`useFetch` is for components. It integrates with SSR, deduplicates requests, and returns reactive refs. `$fetch` is for imperative calls: event handlers, utility functions, anywhere you'd use plain `fetch()`. We'll use `$fetch` later when we build favorites and reviews.

\*\*Warning: Don't destructure the data ref\*\*

`const { data: springs } = useFetch(...)` gives you a ref. Access it as `springs.value` in script, or plain `springs` in the template. If you destructure deeper (`const { data: { value: springs } }`), you'll lose reactivity and the template won't update.

## Try It

Start the dev server and visit `http://localhost:3000/springs`. You should see:

1. "Browse Hot Springs" heading with "17 springs found"
2. A two-column grid of spring cards
3. Each card shows the name, truncated description, location, temperature, and a type badge
4. Clicking a card navigates to `/springs/[id]` (still a placeholder page)

Refresh the page. The springs should appear instantly because `useFetch` runs during SSR and the data is embedded in the HTML payload. Open your browser's Network tab and you won't see a separate API call on the initial page load.

Navigate away and come back. Now you'll see the API call in the Network tab because the client-side navigation triggers a fresh fetch. Same composable, different behavior depending on context.

## Commit

```bash
git add -A && git commit -m "feat(browse): wire up browse page with useFetch and SpringCard"
```

## Done-When

- [ ] The browse page loads and displays all 17 hot springs in a grid
- [ ] Loading state shows "Loading springs..." briefly on client-side navigation
- [ ] Each spring renders as a `SpringCard` with name, description, location, and type
- [ ] You can explain the difference between `useFetch` and `$fetch`

## Solution

```vue title="app/pages/springs/index.vue"
<script setup lang="ts">
const { data: springs, status } = useFetch("/api/springs");
</script>

<template>
  <div>
    <div>
      <h1>
        Browse Hot Springs
      </h1>
      <p>
        {{ springs?.length ?? 0 }} springs found
      </p>
    </div>

    <!-- Loading state -->
    <div v-if="status === 'pending'">
      Loading springs...
    </div>

    <!-- Results -->
    <div v-else-if="springs?.length">
      <SpringCard v-for="spring in springs" :key="spring.id" :spring="spring" />
    </div>

    <!-- Empty state -->
    <div v-else>
      No springs match your filters. Try broadening your search.
    </div>
  </div>
</template>
```


---

[Full course index](/academy/llms.txt) · [Sitemap](/academy/sitemap.md)
