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 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:

// 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:

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:

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 {{ }}.

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

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>