Vercel Logo

Search & Filtering

Here's something that would take real effort in a React app. You want the browse page to filter springs by region, type, and a search term. The filters need to update the URL query parameters, the query parameters need to trigger a new API call, and the API needs to return the filtered results.

In Next.js, you'd wire up useSearchParams, call router.push to update the URL, and either refetch on the client or restructure as a Server Component that reads params on every request. It's doable, but it's plumbing.

In Nuxt, useFetch accepts a reactive query option. When the reactive values change, it refetches. The server route reads query params with getQuery. Connect the two and the whole pipeline works without any manual wiring.

Outcome

Add search and filter controls to the browse page that filter springs by region, type, and keyword.

Fast Track

  1. Add query parameter parsing to the springs server route
  2. Create reactive filter refs and a computed query object on the browse page
  3. Pass the query object to useFetch and verify live filtering

Hands-on exercise 2.4

Add filtering to both the server route and the browse page.

Requirements:

  1. Update server/api/springs/index.get.ts to filter by region, type, and search query parameters
  2. Add ref values for search, region, and type on the browse page
  3. Create a computed object that builds query parameters from the filter refs
  4. Pass the computed query to useFetch so it refetches automatically
  5. Add filter UI: a text input for search, dropdowns for region and type, and a clear button

Implementation hints:

  • getQuery(event) returns all query parameters as an object. ?region=Iceland&type=wild becomes { region: "Iceland", type: "wild" }
  • useFetch accepts a query option that can be a reactive ref or computed. When it changes, Nuxt refetches
  • Filters should be case-insensitive on the server
  • Empty string values should be excluded from the query object so they don't get sent as ?region=

Let's update the server route first. Right now it returns everything. We need it to filter based on query parameters:

server/api/springs/index.get.ts
import type { Spring } from "~/types/spring";
import springs from "~/server/data/springs.json";
 
export default defineEventHandler((event) => {
  const query = getQuery(event);
 
  let results = springs as Spring[];
 
  // Filter by region
  if (query.region && typeof query.region === "string") {
    results = results.filter(
      (s) =>
        s.location.region.toLowerCase() ===
        query.region!.toString().toLowerCase()
    );
  }
 
  // Filter by type
  if (query.type && typeof query.type === "string") {
    results = results.filter((s) => s.type === query.type);
  }
 
  // Search by name or description
  if (query.search && typeof query.search === "string") {
    const term = query.search.toLowerCase();
    results = results.filter(
      (s) =>
        s.name.toLowerCase().includes(term) ||
        s.description.toLowerCase().includes(term)
    );
  }
 
  return results;
});

Each filter only applies if the parameter exists. No parameters? All 17 springs. ?region=Iceland? Two springs. ?search=cave? Just Goldmeyer. The filters compose, so ?region=Pacific Northwest&type=wild narrows it further.

Now the browse page. Here's where Vue's reactivity model shines. We need three pieces of state, a computed object that builds query params from them, and a useFetch that reacts to changes:

app/pages/springs/index.vue
<script setup lang="ts">
const search = ref("");
const region = ref("");
const type = ref("");
 
const queryParams = computed(() => {
  const params: Record<string, string> = {};
  if (search.value) params.search = search.value;
  if (region.value) params.region = region.value;
  if (type.value) params.type = type.value;
  return params;
});
 
const { data: springs, status } = useFetch("/api/springs", {
  query: queryParams,
});
 
const regions = [
  "Pacific Northwest",
  "Rocky Mountains",
  "Southwest",
  "Northeast",
  "Iceland",
  "Japan",
  "New Zealand",
];
 
const types = ["wild", "developed", "resort"];
</script>

When search, region, or type change, queryParams recomputes. When queryParams changes, useFetch refetches. No useEffect. No router.push. No debounce handler. The reactivity chain handles everything.

ref("") is Vue's useState(""). The difference: you access the value with .value in script and without it in the template. search.value = "cave" in script, {{ search }} in the template. This is the one Vue convention that catches every React developer. You'll forget .value at least once. The dev tools will remind you.

Now the filter UI:

app/pages/springs/index.vue
<template>
  <div>
    <div>
      <h1>
        Browse Hot Springs
      </h1>
      <p>
        {{ springs?.length ?? 0 }} springs found
      </p>
    </div>
 
    <!-- Filters -->
    <div>
      <input v-model="search" type="text" placeholder="Search by name or description..." />
      <select v-model="region">
        <option value="">All Regions</option>
        <option v-for="r in regions" :key="r" :value="r">{{ r }}</option>
      </select>
 
      <select v-model="type">
        <option value="">All Types</option>
        <option v-for="t in types" :key="t" :value="t">{{ t }}</option>
      </select>
 
      <button v-if="search || region || type" @click="search = ''; region = ''; type = ''">
        Clear filters
      </button>
    </div>
 
    <div v-if="status === 'pending'">
      Loading springs...
    </div>
 
    <div v-else-if="springs?.length">
      <SpringCard v-for="spring in springs" :key="spring.id" :spring="spring" />
    </div>
 
    <div v-else>
      No springs match your filters. Try broadening your search.
    </div>
  </div>
</template>

v-model is Vue's two-way binding. It replaces the value + onChange pattern in React. v-model="search" means: set the input's value to search, and update search when the user types. One directive does both jobs.

The clear button uses @click, which is shorthand for v-on:click. It resets all three refs to empty strings, which triggers queryParams to recompute, which triggers useFetch to refetch with no filters. The whole chain cascades from three assignments.

v-model is two-way binding

In React, form inputs are controlled with value + onChange. In Vue, v-model handles both directions. It works on inputs, selects, textareas, and custom components. When you see v-model, think "React controlled component in one attribute."

useFetch refetches on every keystroke

Since search is a ref wired directly to the input via v-model, every keystroke updates the ref, which recomputes queryParams, which triggers a refetch. For a local JSON file this is fine. For a real database, you'd want to debounce. Nuxt doesn't include a debounce utility, but you can wrap the search ref with a watchDebounced from VueUse or roll your own.

Try It

Visit http://localhost:3000/springs and try the filters:

  1. Select "Iceland" from the region dropdown. You should see 2 springs: Blue Lagoon and Landmannalaugar
  2. Change the type to "wild." Only Landmannalaugar should remain
  3. Clear the filters. All 17 springs return
  4. Type "cave" in the search box. Goldmeyer Hot Springs should appear (its description mentions a cave pool)
  5. Type "taco" in the search box. No results. The empty state message appears

Watch the spring count update in real time as you type and select filters. The URL stays the same (filters live in component state, not the URL), but the API calls update with the new query parameters.

Commit

git add -A && git commit -m "feat(search): add server-side filtering and browse page controls"

Done-When

  • /api/springs?region=Iceland returns 2 springs
  • /api/springs?type=wild returns only wild springs
  • /api/springs?search=cave returns Goldmeyer
  • Filters on the browse page update results in real time without manual refetching
  • The clear button resets all filters and shows all 17 springs

Solution

server/api/springs/index.get.ts
import type { Spring } from "~/types/spring";
import springs from "~/server/data/springs.json";
 
export default defineEventHandler((event) => {
  const query = getQuery(event);
 
  let results = springs as Spring[];
 
  if (query.region && typeof query.region === "string") {
    results = results.filter(
      (s) =>
        s.location.region.toLowerCase() ===
        query.region!.toString().toLowerCase()
    );
  }
 
  if (query.type && typeof query.type === "string") {
    results = results.filter((s) => s.type === query.type);
  }
 
  if (query.search && typeof query.search === "string") {
    const term = query.search.toLowerCase();
    results = results.filter(
      (s) =>
        s.name.toLowerCase().includes(term) ||
        s.description.toLowerCase().includes(term)
    );
  }
 
  return results;
});
app/pages/springs/index.vue
<script setup lang="ts">
const search = ref("");
const region = ref("");
const type = ref("");
 
const queryParams = computed(() => {
  const params: Record<string, string> = {};
  if (search.value) params.search = search.value;
  if (region.value) params.region = region.value;
  if (type.value) params.type = type.value;
  return params;
});
 
const { data: springs, status } = useFetch("/api/springs", {
  query: queryParams,
});
 
const regions = [
  "Pacific Northwest",
  "Rocky Mountains",
  "Southwest",
  "Northeast",
  "Iceland",
  "Japan",
  "New Zealand",
];
 
const types = ["wild", "developed", "resort"];
</script>
 
<template>
  <div>
    <div>
      <h1>
        Browse Hot Springs
      </h1>
      <p>
        {{ springs?.length ?? 0 }} springs found
      </p>
    </div>
 
    <div>
      <input v-model="search" type="text" placeholder="Search by name or description..." />
      <select v-model="region">
        <option value="">All Regions</option>
        <option v-for="r in regions" :key="r" :value="r">{{ r }}</option>
      </select>
      <select v-model="type">
        <option value="">All Types</option>
        <option v-for="t in types" :key="t" :value="t">{{ t }}</option>
      </select>
      <button v-if="search || region || type" @click="search = ''; region = ''; type = ''">
        Clear filters
      </button>
    </div>
 
    <div v-if="status === 'pending'">
      Loading springs...
    </div>
 
    <div v-else-if="springs?.length">
      <SpringCard v-for="spring in springs" :key="spring.id" :spring="spring" />
    </div>
 
    <div v-else>
      No springs match your filters. Try broadening your search.
    </div>
  </div>
</template>