Vercel Logo

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's useFetch composable works in pages and components. During SSR, it fetches data on the server and serializes the result into the payload. During client navigation, it calls the API from the browser. The same call covers both contexts.

You'll use it to load the 17 hot springs on the browse page.

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:

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

The React version manages state and the request lifecycle in the component. Compare it with the Nuxt version:

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

useFetch handles the request, caching, SSR serialization, and reactive updates. The data ref changes when the response arrives, and status reports the request lifecycle.

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

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>

The template introduces a few Vue conventions:

v-for="spring in springs" replaces springs.map((spring) => ...). Put :key on the element with v-for. The right side accepts JavaScript expressions, but use computed for substantial transformations so Vue can cache the result until its 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 {{ }}.

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.

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 return. Client-side navigation triggers a fresh API call, so it appears in the Network tab even though the page uses the same composable.

Commit

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

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>

Was this helpful?

supported.