Fetching Data with Query Params in Next.js Server Components

Fetching Data with Query Params in Next.js Server Components

August 6, 2025
10 min read
nextjsreactserver components

Learn how to fetch data with query parameters in Next.js, and compare client-side fetching to server-side re-rendering for dynamic web apps.

Server components are a powerful feature of Next.js that allow you to render HTML on the server, improving performance and SEO. However, when using query parameters, there are things that you need to be aware of.

Combination of Server and Client Components

Next.js allows you to combine server and client components in the same page. This can be useful when you need to fetch data from an API but also support client-side interactions.

In the example below, we have a server component that fetches data from an API (using server action) and a client component that allows the user to see the list of items (in this case, a list of cities), search for a city or change the sorting order.

import { CitiesList } from '@/components/CitiesList';
import { getCities } from './actions/cities';

type SearchParams = {
  search?: string;
  sort?: string;
};

export default async function Home({
  searchParams,
}: {
  searchParams: Promise<SearchParams>;
}) {
  const params = await searchParams;
  const cities = await getCities(params.search, params.sort);

  return (
    <div className="overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-xl dark:border-gray-700 dark:bg-gray-800">
      <div className="border-gray-200 p-6 dark:border-gray-700">
        <h2 className="text-2xl font-semibold text-gray-800 dark:text-white">
          Cities Directory
        </h2>
      </div>
      <CitiesList cities={cities} searchParams={params} />
    </div>
  );
}

getCities is a server action that fetches array of cities from a JSON file. It accepts two parameters: search and sort which are filtering and sorting the cities.

export async function getCities(
  search?: string,
  sort?: string,
) {
  const searchTerm = search?.toLowerCase() || '';

  const filteredCities = cities.filter(city =>
    city.name.toLowerCase().includes(searchTerm) ||
    city.country.toLowerCase().includes(searchTerm)
  );

  return filteredCities.sort((a, b) => {
    if (sort === 'name-desc') {
      return b.name.localeCompare(a.name);
    } else if (sort === 'country') {
      return a.country.localeCompare(b.country);
    } else if (sort === 'country-desc') {
      return b.country.localeCompare(a.country);
    }

    return a.name.localeCompare(b.name);
  });
}

CitiesList client component is a simple component that displays the list of cities and allows the user to search for a city or change the sorting order. Search functionality is using debouncing to avoid too many requests to the server. Part of the code that we are going to focus on is the function for setting the query params (full code is available here).

const updateSearchParams = (updates: Record<string, string>) => {
  const params = new URLSearchParams(searchParamsHook.toString());

  Object.entries(updates).forEach(([key, value]) => {
    if (value) {
      params.set(key, value);
    } else {
      params.delete(key);
    }
  });

  if (params.toString()) {
    router.replace(`/?${params.toString()}`);
  } else {
    router.replace('/');
  }
}

The updateSearchParams function is used to update the query params in the URL. It takes an object of key-value pairs and updates the query params in the URL. The function will be called when the user changes the sorting order or searches for a city. The important part is the router.replace function. It's going to cause a server component tree to be re-executed on the server to generate updated HTML and streamed back to the browser. The user won't see any indication that the page is reloading and new data will replace the old one. We could easily add a loading state to the page to indicate that the page is reloading, but it's not the focus of this article.

When user completes typing in the search input, the query params will be updated and we can see a GET request being sent to the server with the new query params: GET /?search=new 200 in 19ms. The server will filter the cities based on the new query params and return updated React elements. Next.js streams server-rendered React output (using React Server Components and Flight) back to the browser, allowing partial hydration. The steps that are happening in the browser are:

  1. The browser receives the stream of React elements and metadata.
  2. The Next.js client runtime parses it and reconstructs the React component tree.
  3. The UI updates to reflect the new state (e.g., filtered cities).

This is all well optimized by the Next.js runtime and works as magic, but it's important to understand what is happening under the hood. If the server component is big and complex, it can be a performance issue to re-execute it on every query param change.

The alternative would be to use client-side fetching of data, where a new request to the server is required which would return only the data and not the React elements. This would require some additional state management and React still needs to render the component tree.

Comparison with Client-Side Fetching

When building dynamic web apps, you often have two main options for fetching data in response to query parameter changes:

Server-Side Re-rendering (Server Components)

With this approach (described in the previous section), changing the query parameters (e.g., via the URL) triggers a new request to the server. The server fetches the data, renders the updated React component tree, and streams the result back to the client. This is the default behavior in Next.js Server Components.

Pros:

  • SEO-friendly: The server returns fully rendered HTML, which is great for search engines.
  • Consistent data: All data fetching happens on the server, so you avoid mismatches between client and server state.
  • No client-side fetching logic: You don’t need to write extra code to fetch and manage data on the client.

Cons:

  • Potential full page re-render: Even small changes (like a search input) cause the whole page (or at least the affected server component subtree) to re-render on the server.
  • Potentially slower perceived updates: There may be a slight delay as the client waits for the server to respond and rehydrate the UI.
  • Loading state is a bit more complex to implement when compared to client-side fetching.

Client-Side Fetching

In this approach, a client component is used to watch for query param changes (or input changes), and fetch data directly from the API using fetch, axios, or a data fetching library like SWR or React Query. The server only returns the initial page; subsequent data updates happen entirely on the client. In order to use client-side fetching we would need a new state to hold the data and we need to create refetchCities function in the CitiesList component to fetch the data and update state. We can also add a loading state to the component to indicate that the data is being fetched. Full code for the client-side fetching is available here.

// New state to hold the data and loading state.
const [cities, setCities] = useState<City[]>(initialCities);
const [isLoading, setIsLoading] = useState(false);

// Function to fetch the data and update the state.
const refetchCities = async () => {
  setIsLoading(true);
  const newCities = await getCities(search, sort);
  setCities(newCities);
  setIsLoading(false);
};

If we want to persist the search and sort params, we can still add them to the URL as query params but without re-rendering the whole page.

const updateSearchParams = (updates: Record<string, string>) => {
  const params = new URLSearchParams(searchParamsHook.toString());

  Object.entries(updates).forEach(([key, value]) => {
    if (value) {
      params.set(key, value);
    } else {
      params.delete(key);
    }
  });

  // Update the URL with the new query params without re-rendering the whole page.
  window.history.pushState({}, '', `/?${params.toString()}`);
};

Pros:

  • We fetch only the data, not the React elements. In case of a large component tree, this can be a performance win.
  • More interactive: Easier to implement features like loading states, infinite scroll, live search, or real-time updates.

Cons:

  • SEO limitations: Data fetched on the client isn’t visible to search engines by default.
  • More client-side code: You need to handle fetching, caching, and error states yourself.
  • Potential for inconsistent state: If not managed carefully, client and server state can get out of sync.

In practice, you can combine both approaches, use server components for the initial render and SEO, and client components for interactive features and incremental updates. Both approaches have their pros and cons and the important part is to understand what is happening under the hood and make an informed decision.