Building a Search Form That Works Without JavaScript

Search is the control most likely to be built as an island first and a form never, which is why so many sites have a search box that does nothing for the first second of a page load. Building the layers in the other order costs almost nothing and produces a better result at every stage. This applies the model from progressive enhancement in modern frameworks to one concrete component.

Prerequisites

Implementation Steps

Three Layers, Each Complete on Its Own The base layer is a GET form that submits to a server route and returns a results page, working with no script at all. The second layer adds CSS for layout and a reserved area for suggestions so nothing shifts. The third layer adds an island that intercepts submission and input to show suggestions and update results in place, while keeping the URL synchronised. Build upward, never downward 1 · GET form → server route → results page shareable URL, working back button, indexable, zero JavaScript 2 · CSS — layout plus a reserved suggestion area the space exists before suggestions do, so nothing shifts when they arrive 3 · island — intercepts submit and input, syncs the URL removable at any moment; the layer beneath still works

Step 1 — The form, which is the whole feature

<!-- Server-rendered. Works before, during and after activation. -->
<form action="/search" method="get" data-island="typeahead" data-trigger="focus">
  <label for="q">Search products</label>
  <input id="q" name="q" type="search" value="{{ query }}" autocomplete="off">
  <button type="submit">Search</button>
  <!-- Reserved, empty, sized: suggestions land here without moving anything. -->
  <div id="suggestions" role="listbox" aria-label="Suggestions" hidden></div>
</form>

Note the trigger: focus. A search box is reached by clicking or tabbing into it, and both produce focus, so the island loads exactly when the visitor signals intent — earlier than a viewport trigger would for a header search, and far later than an eager one.

Step 2 — Results rendered from the query string

The route reads q and renders results. That single decision gives shareable URLs, a working back button, indexable result pages and a cacheable response, none of which need any client code. It also gives the island something to synchronise with rather than a parallel state to maintain.

Step 3 — The island as an interception, not a replacement

export function mount(form) {
  const input = form.elements.q;
  const list = form.querySelector('#suggestions');
  let controller;

  // Suggestions are additive: if this request fails, the form still submits.
  input.addEventListener('input', async () => {
    controller?.abort();
    controller = new AbortController();
    const q = input.value.trim();
    if (q.length < 2) { list.hidden = true; return; }
    try {
      const html = await fetch(`/search/suggest?q=${encodeURIComponent(q)}`,
        { signal: controller.signal }).then((r) => r.text());
      list.innerHTML = html;                 // server-rendered fragment
      list.hidden = false;
    } catch { /* aborted or offline: leave the baseline alone */ }
  });

  form.addEventListener('submit', async (event) => {
    if (!window.fetch) return;               // no interception, native submit
    event.preventDefault();
    const url = `/search?q=${encodeURIComponent(input.value)}`;
    const html = await fetch(url, { headers: { 'x-fragment': 'results' } })
      .then((r) => r.text());
    document.querySelector('#results').innerHTML = html;
    // The URL is the source of truth for both paths — keep it correct.
    history.pushState({}, '', url);
  });
}

Step 4 — Keep both paths converging on one representation

The island pushes the same URL the form would have produced, and the server renders the same results for that URL. A visitor who shares the link, presses back, or reloads gets exactly what they were looking at, because there is only one description of the current state and it lives in the address bar.

Verification

Three States, All of Them Real With scripting disabled the form submits and the server returns results. During the activation window the form still submits natively, so an early Enter press is never lost. Once active, suggestions appear and submission updates results in place while the URL stays correct. All three must be tested, because visitors experience all three. STATE EXPECTED BEHAVIOUR scripting disabled submits, results page renders, URL carries the query activation pending Enter still submits natively — nothing is swallowed fully active suggestions appear, results update in place, URL stays correct

The middle row is the one that distinguishes this design from the usual one. Throttle the connection, load the page, type a query and press Enter before the island has activated. The native submission runs, the visitor gets results, and nothing was lost — because the form was always a form. A search box implemented as an island-only control swallows that keypress entirely, and the visitor concludes the site is broken rather than slow.

Two further checks close the loop. Press back after an in-place update and confirm the previous results return, which verifies the history entry carries enough state. And block the suggestions endpoint in the network panel: suggestions should simply not appear, with no error state, no spinner stuck on screen and a form that still submits.

Why This Order Is Cheaper

Building the form first feels like extra work and is usually less, for three reasons.

The server-rendered results page has to exist anyway — for search-engine indexing, for shared links, for the no-JavaScript fraction of traffic, and for the error case. Building it first means it is the same code path the enhancement uses rather than a second implementation maintained separately.

The enhancement gets smaller. When the baseline handles submission, navigation and rendering, the island is a suggestions list and an interception — a few kilobytes rather than a small application. That directly reduces the boundary cost measured in estimating the cost of a hydration boundary.

And the failure modes collapse into one. Offline, blocked script, failed chunk, slow connection, unsupported browser, extension interference — all of them degrade to the same well-tested path. A component built the other way round needs a distinct answer for each, and in practice gets one for none of them.

One URL, Both Paths The form submission and the island update converge on the same query string, so a shared link, a reload and the back button all reproduce the same results regardless of which path produced them. The URL is the only description of the current state form submit → /search?q=boots → server renders results works with no script, indexable, shareable island submit → same URL pushed → results replaced in place same query string, same server implementation back, reload and share behave identically on both paths

Troubleshooting

Why a GET form rather than a POST?

Because search results are a view of data, not a change to it. GET puts the query in the URL, which makes results shareable, bookmarkable, cacheable and navigable with the back button — four properties you would otherwise re-implement in JavaScript. It also means the enhancement has something to synchronise with: the island updates the same query string the form would have submitted, so both paths converge on one representation.

Does the typeahead need its own endpoint?

It needs one that returns a fragment or JSON rather than a full page, but it should read the same query parameters and use the same search implementation. Two search implementations drift, and the drift shows up as results that differ depending on whether the visitor pressed Enter or waited for suggestions — a bug that is very hard to explain to anyone.

What should happen if the suggestions request fails?

Nothing visible beyond the suggestions not appearing. The form underneath still works: pressing Enter submits it and the server returns results. This is the practical benefit of building the layers in this order — a failure in the enhancement degrades to the baseline instead of leaving a visitor with a search box that does nothing.

← Back to Progressive Enhancement in Modern Frameworks