---
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-10-16T17:45:50.362Z"
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

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:

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

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

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

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:

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

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