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

Visited tracking follows the same storage and route pattern as favorites. The new work is a dashboard that derives regional coverage, hottest temperature, and wild-spring count from the saved visits.

In React, this dashboard might use several `useMemo` hooks with dependency arrays. Vue's `computed` tracks dependencies automatically and allows one computed value to depend on another. The dashboard can derive every statistic from the visited list and spring data without storing duplicate state.

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

The visited page computes its statistics from the spring 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>
```

Every value in `stats` is derived. When the visited list refetches, `visitedSprings` and `stats` recompute, updating the dashboard without duplicate state.

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

The React version declares dependencies for both `useMemo` calls. Vue records the reactive values read by each `computed` callback instead.

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 values can depend on each other\*\*

`visitedSprings` depends on `visited` and `allSprings`, while `stats` depends on `visitedSprings`. Vue tracks that chain and recomputes the affected values when the source data changes.

\*\*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)
