2 Ways to Build Live Search in Remix (Without Page Reloads)

Explore two ways to build search in Remix. The classic form-based method and the more dynamic, programmable approach

·Matija Žiberna·
2 Ways to Build Live Search in Remix (Without Page Reloads)

Dynamic search (also called live search or type-ahead) is a staple UX pattern. In Remix, there are two canonical ways to fetch new data as the user types, without a full page reload.

Both patterns are first-class Remix techniques. Use the one that matches your UX goals.


1. Classic <Form method="GET"> — Submit on Enter

How it works

  1. The user types in an input field.
  2. When they press Enter (or click a submit button), the browser performs a GET request.
  3. Remix runs the loader for the route.
  4. The page navigates to the updated URL (for example, ?search=apple).
  5. The loader returns filtered data, and the component re-renders.

Example

// routes/search.tsx

export const loader = async ({ request }: LoaderFunctionArgs) => {
  const url = new URL(request.url);
  const query = url.searchParams.get("search") ?? "";
  const results = query ? await fetchProducts(query) : [];
  return json({ results, query });
};

export default function SearchPage() {
  const { results, query } = useLoaderData<typeof loader>();
  const [search, setSearch] = useState(query);

  return (
    <Form method="GET">
      <TextField
        label="Product"
        name="search"
        value={search}
        onChange={setSearch}
        autoComplete="off"
      />

      {/* Optional submit button */}
      {/* <button type="submit">Search</button> */}

      <ResultsList items={results} />
    </Form>
  );
}

Pros and Cons

ProsCons
UXSimple behavior. The URL is always up to date and shareable.Requires pressing Enter to submit.
DevNo extra hooks or state handling needed.Causes full navigation, so scroll position resets and parent loaders run again.

When to use it

  • You want a basic search that submits on Enter.
  • You want the URL to reflect the current query for sharing or bookmarking.
  • You prefer the simplest possible code.

How it works

  1. The user types.
  2. In the input’s onChange, you call fetcher.submit({ search }, { method: "GET" }).
  3. Remix runs the same loader.
  4. The page does not navigate; the loader result goes into fetcher.data.
  5. You render the results from fetcher.data for a fluid, real-time experience.

Example (with debounce and optional URL sync)

// routes/search.tsx
import debounce from "lodash.debounce";

export default function SearchPage() {
  const loaderData = useLoaderData<typeof loader>();
  const fetcher = useFetcher<typeof loader>();

  const [search, setSearch] = useState(loaderData.query);
  const results = fetcher.data?.results ?? loaderData.results;

  const debouncedSubmit = useMemo(
    () =>
      debounce((value: string) => {
        fetcher.submit({ search: value }, { method: "GET" });
      }, 300),
    [fetcher]
  );

  useEffect(() => {
    const params = new URLSearchParams(window.location.search);
    if (search) params.set("search", search);
    else params.delete("search");
    window.history.replaceState(null, "", `?${params.toString()}`);
  }, [search]);

  const handleSearch = (value: string) => {
    setSearch(value);
    debouncedSubmit(value);
  };

  return (
    <>
      <TextField
        label="Product"
        value={search}
        onChange={handleSearch}
        autoComplete="off"
      />
      <ResultsList items={results} />
    </>
  );
}

Pros and Cons

ProsCons
UXLive results with no page reload. Scroll and other state stay intact.Needs extra code for debounce and optional URL sync.
DevReuses the same loader logic. No redundant queries.You must handle fetcher.data typing.

When to use it

  • You want type-ahead search with instant feedback.
  • Avoiding full navigation is important (for example, you have large parent loaders).
  • You want fine-grained control over network calls and history.

Decision Matrix

ScenarioRecommended Pattern
Search on Enter or Submit Button<Form method="GET">
Live search while typinguseFetcher
Shareable URLEither works; <Form> handles it automatically, useFetcher needs manual history sync.
Expensive parent loaders you want to avoiduseFetcher
Simplest implementation<Form>

Key Takeaways

  • For traditional search where the user confirms the input, <Form method="GET"> is straightforward, keeps your URL in sync, and is SEO-friendly.
  • For dynamic, instant search, useFetcher allows live updates without a full reload, but you handle debouncing and history updates.
  • Both approaches reuse your loader logic — no need to duplicate queries or validation.

Use the right tool for the UX you want. Keep the loader as your single source of truth. And if you go with useFetcher, debounce your requests to avoid overwhelming your server.

Thanks, Matija

10
Enjoyed this article?
Subscribe to my newsletter for more insights and tutorials.
Matija Žiberna
Matija Žiberna
Full-stack developer, co-founder

I'm Matija Žiberna, a self-taught full-stack developer and co-founder passionate about building products, writing clean code, and figuring out how to turn ideas into businesses. I write about web development with Next.js, lessons from entrepreneurship, and the journey of learning by doing. My goal is to provide value through code—whether it's through tools, content, or real-world software.

You might be interested in