---
title: "Visited Tracking"
description: "Build the visited tracking feature with server routes, a toggle button on the detail page, and a stats dashboard on the visited page."
canonical_url: "https://vercel.com/academy/nuxt-on-vercel/visited-tracking"
md_url: "https://vercel.com/academy/nuxt-on-vercel/visited-tracking.md"
docset_id: "vercel-academy"
doc_version: "1.0"
last_updated: "2026-05-03T22:24:04.138Z"
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>

# Visited Tracking

# Visited Tracking

Favorites are aspirational. Visited is proof. The pattern is almost identical to favorites, so this lesson will move faster. Where it gets interesting is the stats dashboard: once you're tracking which springs you've visited, you can compute how many regions you've covered, your hottest soak, and how many wild springs you've checked off.

This is where `computed` really earns its keep. In React, a stats dashboard like this means multiple `useMemo` hooks with carefully managed dependency arrays. Miss one dependency and the stats go stale. In Vue, `computed` tracks dependencies automatically, and you can chain computed values together without thinking about it. We'll derive all the stats from the visited list and the springs data without storing any of it.

## Outcome

Build visited tracking with server routes, a toggle button, and a stats dashboard.

## Fast Track

1. Create GET, POST, and DELETE routes for visited in `server/api/user/visited/`
2. Add a "Mark as Visited" toggle to the detail page
3. Build the visited page with stats and a spring list

## Hands-on exercise 4.2

Build the visited feature, following the same pattern as favorites.

**Requirements:**

1. Create `server/api/user/visited/index.get.ts`, `[springId].post.ts`, and `[springId].delete.ts`
2. Add a "Mark as Visited" toggle button on the detail page next to the favorites button
3. Update `app/pages/visited.vue` with the auth middleware, visited springs list, and stats
4. Stats should show: total visited, number of regions, hottest spring temperature, and wild springs count

**Implementation hints:**

- The API routes follow the exact same pattern as favorites. The storage utility already handles `visited` in the `UserData` interface
- The stats dashboard uses `computed` to derive values from the visited springs. No separate API call needed
- `new Set(springs.map(s => s.location.region)).size` gives you the region count in one line

The API routes mirror favorites. Here's the POST route as an example:

```typescript title="server/api/user/visited/[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.visited.some((v) => v.springId === springId)) {
    data.visited.push({ springId, visitedAt: new Date().toISOString() });
    await setUserData(session.user.id, data);
  }

  return { success: true };
});
```

On the detail page, add the visited toggle next to the favorites button. The script additions:

```vue title="app/pages/springs/[id].vue — script additions"
const { data: userVisited } = await useFetch("/api/user/visited", {
  default: () => [],
});

const isVisited = computed(() =>
  userVisited.value?.some(
    (v: { springId: string }) => v.springId === spring.value?.id
  )
);

async function toggleVisited() {
  if (!spring.value) return;
  await $fetch(`/api/user/visited/${spring.value.id}`, {
    method: isVisited.value ? "DELETE" : "POST",
  });
  userVisited.value = await $fetch("/api/user/visited");
}
```

And the button in the template, next to the favorites button:

```vue
<button @click="toggleVisited">
  {{ isVisited ? "Visited ✓" : "Mark as Visited" }}
</button>
```

Now the interesting part. The visited page computes stats from the springs data:

```vue title="app/pages/visited.vue"
<script setup lang="ts">
import type { Spring } from "~/types/spring";

definePageMeta({
  middleware: "auth",
});

const { data: visited } = await useFetch("/api/user/visited", {
  default: () => [],
});

const { data: allSprings } = await useFetch<Spring[]>("/api/springs", {
  default: () => [],
});

const visitedSprings = computed(() => {
  const visitedIds = new Set(
    visited.value?.map((v: { springId: string }) => v.springId) ?? []
  );
  return allSprings.value?.filter((s) => visitedIds.has(s.id)) ?? [];
});

const stats = computed(() => {
  const springs = visitedSprings.value;
  if (!springs.length) return null;

  const regions = new Set(springs.map((s) => s.location.region));
  const types = springs.reduce(
    (acc, s) => {
      acc[s.type] = (acc[s.type] || 0) + 1;
      return acc;
    },
    {} as Record<string, number>
  );

  return {
    total: springs.length,
    regions: regions.size,
    hottest: Math.max(...springs.map((s) => s.temperature.max)),
    types,
  };
});
</script>
```

Everything in `stats` is derived. When the user marks a new spring as visited and the favorites list refetches, `visitedSprings` recomputes, `stats` recomputes, and the dashboard updates. No state management library, no manual cache invalidation. The reactivity chain handles it.

Here's what the same logic looks like in React:

```tsx
// React version — for comparison only
const visitedSprings = useMemo(() => {
  const ids = new Set(visited.map(v => v.springId));
  return allSprings.filter(s => ids.has(s.id));
}, [visited, allSprings]);

const stats = useMemo(() => {
  if (!visitedSprings.length) return null;
  const regions = new Set(visitedSprings.map(s => s.location.region));
  return {
    total: visitedSprings.length,
    regions: regions.size,
    hottest: Math.max(...visitedSprings.map(s => s.temperature.max)),
  };
}, [visitedSprings]);
```

Two `useMemo` hooks, two dependency arrays. Get one dependency wrong and the stats silently go stale. Vue's `computed` eliminates this entire class of bug because it tracks what you read, not what you declare.

The template renders the stats as a grid of cards:

```vue title="app/pages/visited.vue — stats template"
<div v-if="stats">
  <div>
    <p>{{ stats.total }}</p>
    <p>Springs Visited</p>
  </div>
  <div>
    <p>{{ stats.regions }}</p>
    <p>Regions</p>
  </div>
  <div>
    <p>{{ stats.hottest }}°F</p>
    <p>Hottest Visited</p>
  </div>
  <div>
    <p>
      {{ stats.types.wild || 0 }}
    </p>
    <p>Wild Springs</p>
  </div>
</div>
```

\*\*Note: computed chains are free\*\*

`visitedSprings` depends on `visited` and `allSprings`. `stats` depends on `visitedSprings`. Vue tracks the entire chain automatically. When the raw data changes, only the affected computed values recompute. No dependency arrays to maintain.

\*\*Warning: Math.max with empty arrays\*\*

`Math.max(...[])` returns `-Infinity`. The `if (!springs.length) return null` guard prevents this, but if you remove it, the stats card would show a confusing number. Always guard against empty arrays when using spread with Math functions.

## Try It

1. Log in and visit a few spring detail pages. Mark 3-4 springs as visited across different regions
2. Navigate to `/visited`. You should see:
   - The stats dashboard with total count, regions, hottest temperature, and wild springs
   - SpringCard components for each visited spring
3. Go back to a detail page and unmark a spring. Return to `/visited` and verify the stats update

## Commit

```bash
git add -A && git commit -m "feat(visited): add visited tracking with stats dashboard"
```

## Done-When

- [ ] POST and DELETE routes for visited work correctly
- [ ] The detail page shows both favorites and visited toggle buttons
- [ ] The visited page shows a stats dashboard with total, regions, hottest, and wild counts
- [ ] Stats update correctly when springs are added or removed

## Solution

```typescript title="server/api/user/visited/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.visited;
});
```

```typescript title="server/api/user/visited/[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.visited.some((v) => v.springId === springId)) {
    data.visited.push({ springId, visitedAt: new Date().toISOString() });
    await setUserData(session.user.id, data);
  }

  return { success: true };
});
```

```typescript title="server/api/user/visited/[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.visited = data.visited.filter((v) => v.springId !== springId);
  await setUserData(session.user.id, data);

  return { success: true };
});
```

```vue title="app/pages/visited.vue"
<script setup lang="ts">
import type { Spring } from "~/types/spring";

definePageMeta({
  middleware: "auth",
});

const { data: visited } = await useFetch("/api/user/visited", {
  default: () => [],
});

const { data: allSprings } = await useFetch<Spring[]>("/api/springs", {
  default: () => [],
});

const visitedSprings = computed(() => {
  const visitedIds = new Set(
    visited.value?.map((v: { springId: string }) => v.springId) ?? []
  );
  return allSprings.value?.filter((s) => visitedIds.has(s.id)) ?? [];
});

const stats = computed(() => {
  const springs = visitedSprings.value;
  if (!springs.length) return null;

  const regions = new Set(springs.map((s) => s.location.region));
  const types = springs.reduce(
    (acc, s) => {
      acc[s.type] = (acc[s.type] || 0) + 1;
      return acc;
    },
    {} as Record<string, number>
  );

  return {
    total: springs.length,
    regions: regions.size,
    hottest: Math.max(...springs.map((s) => s.temperature.max)),
    types,
  };
});
</script>

<template>
  <div>
    <div>
      <h1>
        Visited Springs
      </h1>
      <p>
        {{ visitedSprings.length }} spring{{
          visitedSprings.length === 1 ? "" : "s"
        }}
        visited
      </p>
    </div>

    <div v-if="stats">
      <div>
        <p>{{ stats.total }}</p>
        <p>Springs Visited</p>
      </div>
      <div>
        <p>{{ stats.regions }}</p>
        <p>Regions</p>
      </div>
      <div>
        <p>{{ stats.hottest }}°F</p>
        <p>Hottest Visited</p>
      </div>
      <div>
        <p>
          {{ stats.types.wild || 0 }}
        </p>
        <p>Wild Springs</p>
      </div>
    </div>

    <div v-if="visitedSprings.length">
      <SpringCard v-for="spring in visitedSprings" :key="spring.id" :spring="spring" />
    </div>

    <div v-else>
      <p>
        You haven't marked any springs as visited yet. Start exploring.
      </p>
      <NuxtLink to="/springs">
        Browse Hot Springs &rarr;
      </NuxtLink>
    </div>
  </div>
</template>
```


---

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