Vercel Logo

Search & Filtering

The browse page needs to filter springs by region, type, and search term. Changes to those values should trigger an API request with the corresponding query parameters.

In Next.js, one approach uses useSearchParams and router.push, followed by a client refetch. A Server Component can instead read the parameters on each request.

In Nuxt, useFetch accepts a reactive query option and refetches when its values change. The server route reads those parameters with getQuery.

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 applies only when its parameter exists. A request with no parameters returns all 17 springs. ?region=Iceland returns two, and ?search=cave returns Goldmeyer. Parameters compose, so adding type=wild narrows the result further.

The browse page needs three refs, a computed query object, and a useFetch call that reacts to that object:

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 changes, queryParams recomputes and useFetch refetches. Vue's dependency tracking connects the state to the request.

ref("") corresponds to useState(""), but scripts access the value through .value. Use search.value = "cave" in script and {{ search }} in the template, where refs are automatically unwrapped.

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, shorthand for v-on:click, to reset the refs. Those assignments recompute queryParams and trigger an unfiltered request.

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>

Was this helpful?

supported.