Show Menu

Interested in contributing?

Submit Post

Share with the biggest community of Svelte enthusiasts in the world

Welcome to Svelte Society, homepage for everything Svelte. Find what you're looking for in the navigation above!

Become a sponsor

Support Svelte Society and get your company featured here. Contact us

recipe  posted by  Kevin Åberg Kultalahti

Building a Progressively Enhanced Search Component

IMPORTANT: This article was written with the assistance of an LLM but manually edited. Most code other than the CSS was written manually.

Example, Source

Most search interfaces break without JavaScript. The input field renders, but nothing happens when you type. Filters don't work. The whole thing is decorative HTML until the bundle loads and hydrates.

It doesn't have to be this way.

Progressive enhancement means building a baseline that works with just HTML, then layering on improvements for browsers that support them. The baseline isn't a fallback or an afterthought. It's the foundation.

This post covers building an omnisearch component that works before JavaScript loads, and gets better once it does. The component filters content by tags, categories, authors, and full-text search. Even if you're not using SvelteKit, you should be able to take inspiration from this and build it in any other meta framework like Next.js, Nuxt or Solid Start.

Why bother?

JavaScript fails more often than you'd think. Network issues, CDN outages, corporate firewalls, ad blockers that get overzealous, a syntax error in a third-party script that tanks your bundle. When it fails, users stare at a broken page.

There's also the hydration gap. SvelteKit sends HTML first, then JavaScript. The browser renders the page, but event handlers aren't attached yet. Between those two events, any JS-dependent UI is dead. Click a button, nothing happens. A GET form, on the other hand, works the moment the HTML arrives.

And URLs matter. When filter state lives in query parameters, users can bookmark results, share links with coworkers, hit the back button to undo. There's no client state to keep in sync, no "your session expired" nonsense. The URL is the state.

GET forms do most of the work

The search uses a plain HTML form with method="GET":

<form method="GET" action="/">
    {#each activeTags as tagValue}
        <input type="hidden" name="tags" value={tagValue} />
    {/each}
    {#if activeCategory}
        <input type="hidden" name="category" value={activeCategory} />
    {/if}
    {#if activeAuthor}
        <input type="hidden" name="author" value={activeAuthor} />
    {/if}
    {#if activeSearch}
        <input type="hidden" name="search" value={activeSearch} />
    {/if}

    <input
        type="search"
        name="q"
        placeholder="Enter query..."
        autocomplete="off"
    />
    <button name="type" value="all">Submit</button>
</form>

When a GET form submits, the browser collects all named inputs and appends them to the URL as query parameters. No JavaScript involved.

The hidden inputs are the trick here. They carry current filters forward when submitting a new search. Say you've already filtered by category=recipe and now you search for "chocolate". Without those hidden inputs, submitting the form would lose your category filter. With them, both values end up in the URL.

The form sends two things: q (what you typed) and type (what kind of filter it is). These are temporary parameters that the server will transform into the real filter values.

The server cleans up the URL

The raw form submission produces URLs like /?q=svelte&type=tags&category=recipe. That works, but it's ugly and inconsistent with how direct links would look. The server normalizes these into clean filter parameters:

// +page.server.ts
import { redirect } from '@sveltejs/kit'
import type { PageServerLoad } from './$types'

export const load: PageServerLoad = ({ url }) => {
    const q = url.searchParams.get('q')
    const type = url.searchParams.get('type')

    if (q && type) {
        const params = new URLSearchParams(url.search)

        params.delete('q')
        params.delete('type')

        if (type === 'all') {
            params.set('search', q)
        } else if (type === 'tags') {
            params.append('tags', q)
        } else if (type === 'category' || type === 'author') {
            params.set(type, q)
        }

        const queryString = params.toString()
        redirect(303, queryString ? `/?${queryString}` : '/')
    }

    return
}

The load function runs on every request. If it sees q and type in the URL, it knows this came from a form submission and needs cleanup.

It builds a new URLSearchParams from the current query string, removes the temporary q and type parameters, then adds the real filter based on what type was. Tags use append because you can have multiple. Category and author use set because there's only one.

Submit /?q=svelte&type=tags, end up at /?tags=svelte. The redirect means users see clean URLs in their address bar, and those URLs work if shared directly.

The 303 status code tells the browser to GET the redirect location. This is the correct response after processing a form: it prevents duplicate submissions if the user refreshes.

Links remove filters, not buttons

Active filters show as pills. Clicking the X removes them. The X is a link, not a button:

<!-- FilterPill.svelte -->
<script lang="ts">
    import { page } from '$app/state'

    let { type, value }: { type: string; value: string } = $props()

    function removeFilterHref() {
        const params = new URLSearchParams(page.url.search)

        if (type === 'tags') {
            const tags = params.getAll('tags')
            params.delete('tags')
            for (const tag of tags) {
                if (tag !== value) {
                    params.append('tags', tag)
                }
            }
        } else {
            params.delete(type)
        }

        const queryString = params.toString()
        return queryString ? `/?${queryString}` : '/'
    }
</script>

<span class="filter-pill">
    <span class="filter-type">{type}</span>: {value}
    <a href={removeFilterHref()}>×</a>
</span>

This distinction matters. Buttons perform actions. Links navigate. Removing a filter is navigation: you're going from one URL state (/?tags=svelte&tags=runes) to another (/?tags=runes). The semantic element for that is an anchor tag.

More practically: links work without JavaScript. The browser follows the href and loads the new page. If this were a button with an onclick handler, it would do nothing until your JavaScript loads.

The removeFilterHref function builds the URL programmatically. It starts with the current query string, then modifies it. Tags need special handling since you can have several active at once—it removes only the one you clicked and keeps the rest. Single-value filters like category and author just get deleted entirely.

$app/state gives access to the current page's URL. It's reactive, so the component stays in sync as the user navigates.

JavaScript adds autocomplete

Everything above works without JavaScript. The enhancement is a dropdown that shows matching filter options as you type—actual autocomplete rather than just submitting whatever you typed.

Detecting JavaScript

First, we check what environment we're in. We do this by importing from $app/environment

import { browser } from '$app/environment';

We then use this in our template to decide what we should show the user.

Fetching filter options

The dropdown needs to know what tags, categories, and authors exist. These come from remote functions—server-side functions that SvelteKit can call from components:

// search.remote.ts
import { query } from '$app/server'
import { posts } from './data'

export const getTags = query(() => {
    const allTags = posts.flatMap(post => post.tags)
    return [...new Set(allTags)]
})

export const getCategories = query(() => {
    const allCategories = posts.map(post => post.category)
    return [...new Set(allCategories)]
})

export const getAuthors = query(() => {
    const allAuthors = posts.map(post => post.author)
    return [...new Set(allAuthors)]
})

The query wrapper marks these as remote functions. They run on the server and return their results to the client. In this case they're extracting unique values from the posts data, but they could query a database or call an API.

Fetch them in the component using Svelte 5's async support:

<script>
    import { getTags, getCategories, getAuthors } from './search.remote.ts'

    const allTags = $derived(await getTags())
    const allCategories = $derived(await getCategories())
    const allAuthors = $derived(await getAuthors())
</script>

The await inside $derived is possible because this component uses Svelte 5's async components feature. The data fetches during server rendering, so the HTML arrives with options already populated.

Filtering options

As the user types, filter the options to show only matches:

let searchQuery = $state('')

const filterOptions = (array: string[], query: string) => {
    return query
        ? array.filter((item) =>
            item.toLowerCase().includes(query.toLowerCase()))
        : []
}

searchQuery is bound to the input field. Every keystroke updates it, which triggers filterOptions to recompute, which updates the dropdown. Standard reactive flow.

Building URLs for dropdown items

When the user clicks a suggestion, it should add that filter to the URL while preserving existing filters:

function buildFilterUrl(type: string, value: string): string {
    const params = new URLSearchParams(page.url.searchParams)

    if (type === 'tags') {
        const existingTags = params.getAll('tags')
        if (!existingTags.includes(value)) {
            params.append('tags', value)
        }
    } else {
        params.set(type, value)
    }

    return `/?${params.toString()}`
}

Same pattern as the filter removal: start with current params, modify them, return a URL string. Tags append (since you can have multiple), others replace.

The dropdown itself

The dropdown renders differently based on whether JavaScript is available:

<div class="dropdown">
    {#if browser}
        {#each filterOptions(allTags, searchQuery) as tagValue}
            <a href={buildFilterUrl('tags', tagValue)} class="dropdown-item">
                {tagValue}
                <span class="dropdown-hint">in tags</span>
            </a>
        {/each}
        {#each filterOptions(allCategories, searchQuery) as categoryValue}
            <a href={buildFilterUrl('category', categoryValue)} class="dropdown-item">
                {categoryValue}
                <span class="dropdown-hint">in category</span>
            </a>
        {/each}
        {#each filterOptions(allAuthors, searchQuery) as authorValue}
            <a href={buildFilterUrl('author', authorValue)} class="dropdown-item">
                {authorValue}
                <span class="dropdown-hint">in author</span>
            </a>
        {/each}
    {:else}
        <button name="type" value="tags" class="dropdown-item">
            <strong>{searchQuery}</strong>
            <span class="dropdown-hint">in tags</span>
        </button>
        <button name="type" value="category" class="dropdown-item">
            <strong>{searchQuery}</strong>
            <span class="dropdown-hint">in category</span>
        </button>
        <button name="type" value="author" class="dropdown-item">
            <strong>{searchQuery}</strong>
            <span class="dropdown-hint">in author</span>
        </button>
    {/if}
</div>

With JavaScript (browser === true), users see filtered suggestions based on what they've typed. Each suggestion is a link that navigates to the filtered URL.

Without JavaScript, users see three buttons that let them specify how to interpret their search. These buttons are inside the form, so clicking one submits the form with that type value. The server then handles the redirect as described earlier. Not as nice as autocomplete, but functional.

Showing the dropdown

The dropdown appears when the search input has focus. This uses CSS, not JavaScript:

.dropdown {
    opacity: 0;
    visibility: hidden;
}

.search-container:focus-within .dropdown {
    opacity: 1;
    visibility: visible;
}

:focus-within matches when any child element has focus. When the user clicks or tabs into the search input, the dropdown appears. When they click outside, it hides. No event listeners required.

What you get

Without JavaScript: search works, adding filters works, removing filters works, URLs are shareable, back button works.

With JavaScript: you also get autocomplete suggestions that filter as you type.

The first is a complete experience. The second makes it smoother.

Taking it further

The JavaScript layer can grow without touching the baseline. A few ideas:

Keyboard navigation. Arrow keys to move through suggestions, Enter to select. The dropdown items are already links, so this is just focus management.

Lazy loading suggestions. For large datasets, fetch suggestions from the server as the user types instead of loading everything upfront. The remote functions already support this—add a parameter for the search query and filter server-side.

Debouncing. If fetching suggestions on every keystroke is too aggressive, debounce the input. The form still works immediately; this only affects the autocomplete.

Recent searches. Store recent queries in localStorage and show them when the input is empty. Another layer on top of the baseline.

Each of these enhances the JavaScript experience while leaving the no-JS baseline untouched. That's the point of progressive enhancement: you can keep adding without breaking the foundation.

Newsletter

Stay up to date with the Svelte ecosystem.

  • This Week in Svelte — A weekly roundup of the best tutorials, libraries, and community highlights
  • Featured Jobs — Hand-picked Svelte job opportunities from top companies
  • Community News — Announcements, events, and updates from the Svelte team

Newsletter data is processed by Plunk, our email service provider. See our Privacy Policy.