Search forms driven by URL search params put the query in the address bar. The form submits with a GET request, the browser encodes the fields into the URL, and the page reads them back from its searchParams prop. The result is a shareable URL that survives a refresh and works with the back button.
Start with a plain GET form. A form defaults to the GET method, and a form without an action submits to the current route, so placing it on the search page keeps everything in one file.
// app/search/page.tsx
export default function SearchPage() {
return <form>
<label htmlFor="query">Search</label>
<input id="query" name="query" />
<button type="submit">Search</button>
</form>
}When a visitor submits, the browser navigates to /search?query=... with the input value in the URL. That is a full page load, and no JavaScript is involved in it.
Read the query on the server
The page receives the query through its searchParams prop. In the App Router this prop is a Promise, so await it before reading the value. This version of the file replaces the one above, with the form kept alongside the results.
// app/search/page.tsx
export default async function SearchPage({
searchParams,
}: {
searchParams: Promise<{ query?: string | string[] }>
}) {
const { query } = await searchParams
const term = (Array.isArray(query) ? query[0] : query) ?? ''
return <p>Results for {term}</p>
}The value can be a string, an array, or missing, because the same key can repeat. Normalize it to one string, then pass it to your search function and render the results in place of the placeholder paragraph.
Once normalized, the term is a plain string you can hand to a database query, an ORM, or a search service.
An empty submission produces an empty string. Decide whether that means show everything or show a prompt, and handle it before the search call runs.
Why URL state wins
Keeping the query in the URL instead of component state makes the results shareable and bookmarkable. A visitor can copy the link, send it to someone, or hit the back button to return to an earlier search.
It also works before JavaScript loads. The GET form posts natively, so search never depends on hydration to function.
Server-rendered search also keeps the first paint fast, because the query is available during render and the results arrive with the page instead of after a second client fetch. A visitor on a slow network still gets a complete page on the first response.
Build it without a full reload
The Form component from next/form replaces the native form to add client-side navigation and prefetching. The URL behavior is identical; only the transition gets faster.
For live, type-as-you-go search, read the URL on the client with useSearchParams and update it as the visitor types, debounced so one keystroke does not fire a request.
That hook has a rule attached. On a prerendered route it pushes the Client Component tree up to the nearest Suspense boundary into client-side rendering, and a production build of a static page that calls it without one fails with "Missing Suspense boundary with useSearchParams". Wrap the component so the rest of the page still prerenders.
See the Next.js Form component explained for that component, and usePathname and useSearchParams explained for reading the URL on the client in a live search.
Rune AI
Key Insights
- Use a GET form so the query lands in the URL.
- Await the searchParams Promise in the page.
- Normalize the value before using it, since it can be an array.
- Handle an empty query by showing all results or a prompt.
- Reach for the Form component for client-side navigation.
Frequently Asked Questions
Why store the query in the URL?
Is searchParams a Promise?
What type does a searchParams value have?
Conclusion
A search form driven by URL search params submits with GET, encodes the query into the URL, and reads it back from the awaited searchParams prop on the server. The URL becomes the single source of truth for what the page shows.
More in this topic
`generateMetadata` Explained with Real Examples
What generateMetadata does, when it runs, and how to use it for real routes: awaited params, deduplicated data fetching, extending parent metadata, and returning a 404 from metadata.
Canonical URLs in Next.js: `metadataBase`, `alternates.canonical`, and Dynamic Pages
How canonical URLs work in the Next.js App Router: setting metadataBase once, writing alternates.canonical per route, handling dynamic segments, and what happens when the base URL is missing.
Open Graph and Twitter Card Metadata in Next.js
How to write Open Graph and Twitter card metadata in the Next.js App Router: the openGraph and twitter fields, automatic card defaults, article tags, and image merge rules.