A server-rendered search engine that still feels instant
Grep searches over a million GitHub repositories for code snippets, files, and paths. Until recently, it was built with Create React App (CRA) as a fully client-rendered SPA—fast once loaded, but with CRA deprecated, it was time to modernize. The goal: keep the instant, SPA-style navigation while improving initial page load and reducing client-side JavaScript.
Next.js offered a path forward with React Server Components. Most of the page could be prerendered for predictable loads, while prefetching preserves the quick navigation feel. The migration centered on three challenges: keeping search input state consistent across navigations, moving data fetching to the server without sacrificing responsiveness, and eliminating layout issues on mobile.
One search bar, two locations
Grep's search input lives in two places: centered on the homepage for a Google-like experience, and in the top navbar on every other route. A naive approach—putting a single server-rendered search bar in the root layout and repositioning it client-side—would cause hydration mismatches and layout shift. Reading searchParams in the root layout would also block prerendering.
The solution was to render two separate SearchBar instances with conditional logic. The header only shows its version on the /search route:
components/header.tsx
"use client";
export function Header() {
const pathname = usePathname();
return (
<header>
<Logo />
{pathname !== '/' && <SearchBar />}
<ThemeToggle />
</header>
);
}
The header lives in the root layout, staying server-rendered across pages:
layout.tsx
import { Header } from '@/components/header';
export default function RootLayout({ children }) {
return (
<html>
<body>
<Header />
{children}
</body>
</html>
);
}
The homepage renders its own centered version:
page.tsx
export function Homepage() {
return (
<div className="search-centered">
<SearchBar />
</div>
);
}
URL-based state keeps the two instances in sync. The homepage's SearchBar updates the query string as the user types, navigating to /search?q=.... The navbar's SearchBar reads from the URL query params on mount. For responsive local feedback during rapid typing, a lightweight React Context manages local input state. Users can start typing on the homepage and continue without losing focus as they transition to results—even though the input physically moves.
Server-initiated queries, client-side continuation
Search results need to appear instantly, but fetching from the server on every keystroke would be slow, and pure client-side fetching would hurt initial load. The team used server-side prefetching with hydration, leveraging SWR and TanStack Query patterns from the React community.
For the first visit to /search?q=react, the server starts fetching immediately without blocking. HTML streams to the client while the query is in-flight—React Server Components can serialize Promises and pass them through a HydrationBoundary, letting TanStack Query resume the request on the client without redundant network calls.
search/page.tsx
import { /* ... */} from "@tanstack/react-query";
import { ResultsClient } from "@/components/results";
import { apiSearch } from "@/lib/api";
import { getFiltersFromRawSearchParams } from "@/lib/utils";
const queryClient = new QueryClient({
defaultOptions: {
dehydrate: {
// Include pending queries so the client can pick them up
shouldDehydrateQuery: (query) =>
defaultShouldDehydrateQuery(query) || query.state.status === 'pending'
}
}
});
const searchOptions = (filters) => ({
queryKey: ["search", filters],
queryFn: () => apiSearch(filters),
});
export default function SearchPage({ searchParams }) {
const filters = getFiltersFromRawSearchParams(searchParams);
// Kick off the query on the server without 'await'
queryClient.prefetchQuery(searchOptions(filters));
return (
// Pass promise to client
<HydrationBoundary state={dehydrate(queryClient)}>
<ResultsClient />
</HydrationBoundary>
);
}
On the client, useSuspenseQuery hydrates from that server-initiated request. Once the initial data arrives, all subsequent searches—triggered by typing, filtering, or pagination—happen client-side. Input changes are debounced with React's useDeferredValue to avoid excessive requests.
components/results.tsx
"use client";
import { useSuspenseQuery } from "@tanstack/react-query";
import { searchOptions } from "@/lib/queries";
export function ResultsClient() {
const filters = useFilters(); // client-side filter + input state
const { data } = useSuspenseQuery(searchOptions(filters));
return <Hits data={data} />;
}
components/results.tsx
'use client';
import { useSuspenseQuery } from '@tanstack/react-query';
import { useDeferredValue, Suspense } from 'react';
import { searchQueryOptions } from '@/lib/queries';
import { useNonOptimisticFilters } from '@/hooks/filters';
import { Hits } from '@/components/hits';
function ResultsInner({ filters }) {
const { data } = useSuspenseQuery(searchQueryOptions(filters));
return <Hits data={data} />;
}
export function ResultsClient() {
const filters = useNonOptimisticFilters();
const deferredFilters = useDeferredValue(filters);
return (
<Suspense fallback={<ResultsSkeleton />}>
<ResultsInner filters={deferredFilters} />
</Suspense>
);
}
Preventing stale and out-of-order results
Rapid typing introduced a subtle bug: older network responses could arrive after newer ones, briefly flashing outdated results. Typing "foo" then deleting to "f" might cause "foo" results to reappear after "f" was already displayed.
Two strategies solved this. First, React's useOptimistic hook handles optimistic state across async updates, so the UI always reflects the latest input regardless of network latency. Second, TanStack Query cache keys—["search", filters]—ensure only the latest request updates the UI, discarding stale responses.
hooks/filters.ts
"use client";
import { useRouter, usePathname, useSearchParams } from "next/navigation";
import { useOptimistic, useTransition } from "react";
export function useFilters() {
const currentFilters = /* ... */;
const [optimisticFilters, setOptimisticFilters] = useOptimistic(
currentFilters,
(_, updatedFilters) => updatedFilters
);
const [isPending, startTransition] = useTransition();
const updateFilters = (updateFn) => {
const newFilters = updateFn(currentFilters);
setOptimisticFilters(newFilters);
// ...
startTransition(() => {
router.replace("/search?" + params.toString());
});
};
return { filters: optimisticFilters, updateFilters, isPending };
}
The implementation pairs useOptimistic with useTransition for instant feedback, eliminating flicker during rapid input changes.
Prefetching the search layout
Next.js automatically prefetches static routes, but not dynamic ones like /search?q=react. To make that transition feel instant, the team explicitly prefetched the shared layout alongside the homepage search bar:
components/prefetch-search-layout.tsx
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
export function PrefetchSearchLayout() {
const router = useRouter();
useEffect(() => {
router.prefetch("/search?q=");
}, [router]);
return null;
}
As the user starts typing on the homepage and navigates, the layout is already cached—making the page transition feel immediate, even before query results resolve.
Stabilizing mobile Safari
A final layout quirk surfaced on mobile Safari: focusing the search input caused unpredictable scroll and zoom behavior. A usePreventScroll hook on the homepage's client component locks page overflow, preventing layout jumps when the input receives focus.
usePreventScroll.tsx
"use client";
import { useEffect } from "react";
export function usePreventScroll(isFocused: boolean) {
useEffect(() => {
document.body.style.overflow = isFocused ? "hidden" : "";
}, [isFocused]);
}
Static shell with dynamic streaming
To push performance further, Grep enabled Next.js's experimental Partial Prerendering (PPR). Before enabling it, search input interactivity was briefly delayed by client-side hydration. PPR eliminates that gap:
- Static shell: Core UI renders immediately for instant visual feedback
- Dynamic streaming: Search results stream in milliseconds later
No additional code changes were needed—just a single flag. It's worth noting that PPR is experimental and only available on canary builds, not yet production-ready.
The result was faster initial loads by combining static UI with streamed dynamic content. Measured improvements came from reduced client-side execution, fewer blocking scripts, and targeted hydration. The migration also shipped dark mode and expanded the search index from 500,000 to 1,000,000 GitHub repositories.
Future work includes private repository indexing with secure authentication via serverless functions and Edge Middleware, plus support for GitHub-style query filters like repo:vercel/next.js or language:typescript.



