Saving Favorites
The app can browse and filter 17 hot springs. Favorites will let authenticated users save the ones they want to visit.
This course stores user data in JSON files so the lesson can focus on Nuxt server utilities, $fetch, and reactive computed values.
The feature includes a persistence utility, API routes, a detail-page toggle, and a favorites page. It also introduces $fetch for imperative requests and requireUserSession for server-side authentication.
Outcome
Build the favorites feature: server-side storage, API routes, toggle button, and favorites page.
Fast Track
- Create
server/utils/user-data.tsfor reading and writing per-user JSON files - Build three API routes: GET, POST, and DELETE for favorites
- Wire up the toggle button on the detail page and the favorites list page
Hands-on exercise 4.1
Build the full favorites pipeline from storage to UI.
Requirements:
- Create
server/utils/user-data.tswithgetUserDataandsetUserDatafunctions - Create
server/api/user/favorites/index.get.tsto list a user's favorites - Create
server/api/user/favorites/[springId].post.tsto add a favorite - Create
server/api/user/favorites/[springId].delete.tsto remove a favorite - Add a favorites toggle button to the detail page
- Update
app/pages/favorites.vueto list favorited springs
Implementation hints:
requireUserSession(event)throws a 401 if the user isn't logged in. Use it for write operations.getUserSession(event)returns null instead of throwing, better for read operations where you want to return an empty array for anonymous users$fetchis Nuxt's imperative fetch function, similar to callingfetch()in a React event handler. Use it in event handlers (@click) for mutations. It's not reactive likeuseFetch- After toggling a favorite, refetch the favorites list to update the UI. Optimistic UI is possible but adds complexity we don't need yet
- User data is stored as JSON files in
.data/users/[userId].json. The.datadirectory is gitignored
First, the storage utility. We're using JSON files instead of a database to keep the focus on Nuxt patterns rather than database setup:
import { readFile, writeFile, mkdir } from "node:fs/promises";
import { join } from "node:path";
import { existsSync } from "node:fs";
const DATA_DIR = join(process.cwd(), ".data", "users");
async function ensureDir() {
if (!existsSync(DATA_DIR)) {
await mkdir(DATA_DIR, { recursive: true });
}
}
function userFilePath(userId: number): string {
return join(DATA_DIR, `${userId}.json`);
}
interface UserData {
favorites: { springId: string; addedAt: string }[];
visited: { springId: string; visitedAt: string }[];
reviews: { id: string; springId: string; body: string; createdAt: string }[];
}
export async function getUserData(userId: number): Promise<UserData> {
await ensureDir();
const filePath = userFilePath(userId);
if (!existsSync(filePath)) {
return { favorites: [], visited: [], reviews: [] };
}
const raw = await readFile(filePath, "utf-8");
return JSON.parse(raw);
}
export async function setUserData(
userId: number,
data: UserData
): Promise<void> {
await ensureDir();
await writeFile(userFilePath(userId), JSON.stringify(data, null, 2));
}This utility lives in server/utils/, which means it's auto-imported in all server routes. No import statement needed when you call getUserData or setUserData. The pattern is the same as how app/composables/ works for Vue composables.
In Next.js, you'd write a similar utility in lib/ or utils/, but you'd need to import it explicitly in every Route Handler that uses it. Nuxt's server auto-imports save that boilerplate.
Now the API routes. The GET endpoint returns the user's favorites (or an empty array if they're not logged in):
export default defineEventHandler(async (event) => {
const session = await getUserSession(event);
if (!session.user) {
return [];
}
const data = await getUserData(session.user.id);
return data.favorites;
});The POST endpoint adds a spring to favorites:
export default defineEventHandler(async (event) => {
const session = await requireUserSession(event);
const springId = getRouterParam(event, "springId");
if (!springId) {
throw createError({ statusCode: 400, statusMessage: "Missing spring ID" });
}
const data = await getUserData(session.user.id);
if (!data.favorites.some((f) => f.springId === springId)) {
data.favorites.push({ springId, addedAt: new Date().toISOString() });
await setUserData(session.user.id, data);
}
return { success: true };
});requireUserSession throws a 401 when the user is logged out. The duplicate guard using some prevents the same spring from being saved twice.
The DELETE endpoint mirrors the POST:
export default defineEventHandler(async (event) => {
const session = await requireUserSession(event);
const springId = getRouterParam(event, "springId");
if (!springId) {
throw createError({ statusCode: 400, statusMessage: "Missing spring ID" });
}
const data = await getUserData(session.user.id);
data.favorites = data.favorites.filter((f) => f.springId !== springId);
await setUserData(session.user.id, data);
return { success: true };
});Now the detail page needs a toggle button. Add this to the script section of app/pages/springs/[id].vue:
<script setup lang="ts">
// ... existing spring fetch code ...
const { loggedIn } = useUserSession();
const { data: userFavorites } = await useFetch("/api/user/favorites", {
default: () => [],
});
const isFavorite = computed(() =>
userFavorites.value?.some(
(f: { springId: string }) => f.springId === spring.value?.id
)
);
async function toggleFavorite() {
if (!spring.value) return;
await $fetch(`/api/user/favorites/${spring.value.id}`, {
method: isFavorite.value ? "DELETE" : "POST",
});
userFavorites.value = await $fetch("/api/user/favorites");
}
</script>And the button in the template:
<div v-if="loggedIn">
<button @click="toggleFavorite">
{{ isFavorite ? "Remove from Favorites" : "Add to Favorites" }}
</button>
</div>$fetch is used here instead of useFetch because this is an imperative action triggered by a click, not reactive data loading. After the toggle, we refetch the favorites list with $fetch to update the computed isFavorite value.
The React equivalent calls fetch() in an event handler and updates state after it resolves. Nuxt's $fetch throws for error status codes, while assigning userFavorites.value updates the dependent computed value. A React application would typically use a state setter or invalidate a query cache.
Finally, the favorites page:
<script setup lang="ts">
import type { Spring } from "~/types/spring";
definePageMeta({
middleware: "auth",
});
const { data: favorites } = await useFetch("/api/user/favorites", {
default: () => [],
});
const { data: allSprings } = await useFetch<Spring[]>("/api/springs", {
default: () => [],
});
const favoriteSprings = computed(() => {
const favoriteIds = new Set(
favorites.value?.map((f: { springId: string }) => f.springId) ?? []
);
return allSprings.value?.filter((s) => favoriteIds.has(s.id)) ?? [];
});
</script>The favorites API returns IDs, so the page fetches both datasets and joins them in a computed property. A Set handles the ID lookup.
In React, you'd do this join with useMemo and a dependency array:
// React version: for comparison only
const favoriteSprings = useMemo(() => {
const ids = new Set(favorites.map(f => f.springId));
return allSprings.filter(s => ids.has(s.id));
}, [favorites, allSprings]);Vue's computed does the same thing without the dependency array. It tracks favorites.value and allSprings.value automatically and recomputes when either changes.
Try It
- Log in and visit a spring detail page, like
/springs/breitenbush-hot-springs - Click "Add to Favorites." The button should change to "Remove from Favorites" with a rose-colored style
- Navigate to
/favorites. Breitenbush should appear as a SpringCard - Go back to the detail page and click "Remove from Favorites." The button reverts
- Check
/favoritesagain. The page should show "No favorites yet"
Commit
git add -A && git commit -m "feat(favorites): add favorites storage, API routes, toggle, and page"Done-When
server/utils/user-data.tsprovidesgetUserDataandsetUserData- POST
/api/user/favorites/breitenbush-hot-springsadds the spring to favorites - DELETE removes it
- The detail page shows a toggle button when logged in
- The favorites page lists all favorited springs with SpringCard components
Solution
import { readFile, writeFile, mkdir } from "node:fs/promises";
import { join } from "node:path";
import { existsSync } from "node:fs";
const DATA_DIR = join(process.cwd(), ".data", "users");
async function ensureDir() {
if (!existsSync(DATA_DIR)) {
await mkdir(DATA_DIR, { recursive: true });
}
}
function userFilePath(userId: number): string {
return join(DATA_DIR, `${userId}.json`);
}
interface UserData {
favorites: { springId: string; addedAt: string }[];
visited: { springId: string; visitedAt: string }[];
reviews: { id: string; springId: string; body: string; createdAt: string }[];
}
export async function getUserData(userId: number): Promise<UserData> {
await ensureDir();
const filePath = userFilePath(userId);
if (!existsSync(filePath)) {
return { favorites: [], visited: [], reviews: [] };
}
const raw = await readFile(filePath, "utf-8");
return JSON.parse(raw);
}
export async function setUserData(
userId: number,
data: UserData
): Promise<void> {
await ensureDir();
await writeFile(userFilePath(userId), JSON.stringify(data, null, 2));
}export default defineEventHandler(async (event) => {
const session = await getUserSession(event);
if (!session.user) {
return [];
}
const data = await getUserData(session.user.id);
return data.favorites;
});export default defineEventHandler(async (event) => {
const session = await requireUserSession(event);
const springId = getRouterParam(event, "springId");
if (!springId) {
throw createError({ statusCode: 400, statusMessage: "Missing spring ID" });
}
const data = await getUserData(session.user.id);
if (!data.favorites.some((f) => f.springId === springId)) {
data.favorites.push({ springId, addedAt: new Date().toISOString() });
await setUserData(session.user.id, data);
}
return { success: true };
});export default defineEventHandler(async (event) => {
const session = await requireUserSession(event);
const springId = getRouterParam(event, "springId");
if (!springId) {
throw createError({ statusCode: 400, statusMessage: "Missing spring ID" });
}
const data = await getUserData(session.user.id);
data.favorites = data.favorites.filter((f) => f.springId !== springId);
await setUserData(session.user.id, data);
return { success: true };
});<script setup lang="ts">
import type { Spring } from "~/types/spring";
definePageMeta({
middleware: "auth",
});
const { data: favorites } = await useFetch("/api/user/favorites", {
default: () => [],
});
const { data: allSprings } = await useFetch<Spring[]>("/api/springs", {
default: () => [],
});
const favoriteSprings = computed(() => {
const favoriteIds = new Set(
favorites.value?.map((f: { springId: string }) => f.springId) ?? []
);
return allSprings.value?.filter((s) => favoriteIds.has(s.id)) ?? [];
});
</script>
<template>
<div>
<div>
<h1>
Your Favorites
</h1>
<p>
{{ favoriteSprings.length }} saved spring{{
favoriteSprings.length === 1 ? "" : "s"
}}
</p>
</div>
<div v-if="favoriteSprings.length">
<SpringCard v-for="spring in favoriteSprings" :key="spring.id" :spring="spring" />
</div>
<div v-else>
<p>
No favorites yet. Browse springs and save the ones that catch your eye.
</p>
<NuxtLink to="/springs">
Browse Hot Springs →
</NuxtLink>
</div>
</div>
</template>Was this helpful?