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
- Add
useFetch("/api/springs")to the browse page - Loop over the results with
v-forand renderSpringCardfor each - Add a loading state
Hands-on exercise 2.2
Connect the browse page to the server route and render the springs data.
Requirements:
- Use
useFetchto load data from/api/springsinapp/pages/springs/index.vue - Display a loading message while data is being fetched
- Render a
SpringCardfor each spring usingv-for - Show an empty state when no springs are returned
- Display the count of springs found
Implementation hints:
useFetchreturns{ data, status, error }.statuscan be"idle","pending","success", or"error"v-foris Vue's loop directive. It replaces.map()in JSX:<div v-for="item in items" :key="item.id">v-if/v-else-if/v-elsehandle conditional rendering. They replace ternaries and&&in JSXSpringCardis auto-imported fromapp/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:
<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:
<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 {{ }}.
Try It
Start the dev server and visit http://localhost:3000/springs. You should see:
- "Browse Hot Springs" heading with "17 springs found"
- A two-column grid of spring cards
- Each card shows the name, truncated description, location, temperature, and a type badge
- 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
SpringCardwith name, description, location, and type - You can explain the difference between
useFetchand$fetch
Solution
<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?