---
title: "Saving Favorites"
description: "Build server routes for saving and removing favorites, create a user data storage utility, and wire up the favorites page and toggle button on the detail page."
canonical_url: "https://vercel.com/academy/nuxt-on-vercel/saving-favorites"
md_url: "https://vercel.com/academy/nuxt-on-vercel/saving-favorites.md"
docset_id: "vercel-academy"
doc_version: "1.0"
last_updated: "2026-05-03T20:42:21.416Z"
content_type: "lesson"
course: "nuxt-on-vercel"
course_title: "Nuxt on Vercel"
prerequisites:  []
---

<agent-instructions>
Vercel Academy — structured learning, not reference docs.
Lessons are sequenced.
Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume.
The lesson shows one path; if the human's project diverges, adapt concepts to their setup.
Preserve the learning goal over literal steps.
Quizzes are pedagogical — engage, don't spoil.
Quiz answers are included for your reference.
</agent-instructions>

# Saving Favorites

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

```typescript title="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):

```typescript title="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:

```typescript title="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:

```typescript title="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`:

```vue title="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:

```vue title="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:

```vue title="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:

```tsx
// 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.

\*\*Note: 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.

\*\*Warning: $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

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

```typescript title="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));
}
```

```typescript title="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;
});
```

```typescript title="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 };
});
```

```typescript title="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 };
});
```

```vue title="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>
```


---

[Full course index](/academy/llms.txt) · [Sitemap](/academy/sitemap.md)
