Vercel Logo

Saving Favorites

We have 17 hot springs. You've browsed them. You've filtered them. You've read about claw-foot bathtubs on a hillside in Utah and a cave pool that requires a reservation. Now you want to remember which ones you actually want to visit. You need favorites.

In React, you'd probably reach for a database, an ORM, and something like React Query or SWR to keep the client in sync. Nuxt's server utilities, $fetch, and reactive computed properties let us build the same feature with less plumbing.

This feature touches every layer of the app: a server utility for persisting user data, API routes for adding and removing favorites, a toggle button on the detail page, and a dedicated favorites page that lists what you've saved. It's the first time we'll use $fetch for imperative API calls and requireUserSession to enforce authentication on the server.

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

Notice requireUserSession instead of getUserSession. If the user isn't logged in, it throws a 401 immediately. No need for a manual check. The duplicate guard (some) prevents the same spring from being favorited 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.

If you're coming from React, this is the pattern you already know: call fetch() inside an event handler, then update state when it resolves. The difference is that $fetch automatically throws on error status codes (no checking response.ok), and updating userFavorites.value triggers the computed chain automatically. In React, you'd either call a state setter or invalidate a React Query cache. Here, the reactivity system handles it.

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 spring IDs, not full spring objects. So we fetch both the favorites list and all springs, then join them with a computed property. The Set makes the lookup fast.

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>