Vercel Logo

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

  1. Create server/utils/user-data.ts for reading and writing per-user JSON files
  2. Build three API routes: GET, POST, and DELETE for favorites
  3. 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:

  1. Create server/utils/user-data.ts with getUserData and setUserData functions
  2. Create server/api/user/favorites/index.get.ts to list a user's favorites
  3. Create server/api/user/favorites/[springId].post.ts to add a favorite
  4. Create server/api/user/favorites/[springId].delete.ts to remove a favorite
  5. Add a favorites toggle button to the detail page
  6. Update app/pages/favorites.vue to 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
  • $fetch is Nuxt's imperative fetch function, similar to calling fetch() in a React event handler. Use it in event handlers (@click) for mutations. It's not reactive like useFetch
  • 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 .data directory 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:

server/utils/user-data.ts
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):

server/api/user/favorites/index.get.ts
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:

server/api/user/favorites/[springId].post.ts
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:

server/api/user/favorites/[springId].delete.ts
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:

app/pages/springs/[id].vue: script additions
<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:

app/pages/springs/[id].vue: template addition
<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:

app/pages/favorites.vue
<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.

Why not return full spring objects from the favorites API?

Keeping the favorites API thin (just IDs and timestamps) means the storage stays small and the spring data has a single source of truth. If a spring's description changes, the favorites page reflects it immediately because it always reads from the springs API.

$fetch throws on error status codes

Unlike browser fetch, $fetch throws an error for 4xx and 5xx responses. If the user's session expires mid-click, the toggle will throw. For a production app, you'd wrap this in a try/catch. For our course app, the error is informative enough.

Try It

  1. Log in and visit a spring detail page, like /springs/breitenbush-hot-springs
  2. Click "Add to Favorites." The button should change to "Remove from Favorites" with a rose-colored style
  3. Navigate to /favorites. Breitenbush should appear as a SpringCard
  4. Go back to the detail page and click "Remove from Favorites." The button reverts
  5. Check /favorites again. 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.ts provides getUserData and setUserData
  • POST /api/user/favorites/breitenbush-hot-springs adds 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

server/utils/user-data.ts
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));
}
server/api/user/favorites/index.get.ts
export default defineEventHandler(async (event) => {
  const session = await getUserSession(event);
  if (!session.user) {
    return [];
  }
 
  const data = await getUserData(session.user.id);
  return data.favorites;
});
server/api/user/favorites/[springId].post.ts
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 };
});
server/api/user/favorites/[springId].delete.ts
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 };
});
app/pages/favorites.vue
<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 &rarr;
      </NuxtLink>
    </div>
  </div>
</template>

Was this helpful?

supported.