Access Search Parameters in Next.js SSR'd Layout

Abstract image used to represent Access Search Parameters in Next.js SSR'd Layout

In Brief

Here, middleware copies the query string into an internal request header so the root layout can read it during that server render. Shared layouts are reused during ordinary clientside navigation, so changing query state belongs in pagelevel logic or a Client Component using useSearchParams.

One of the projects I've been working on recently has been the replatforming of the search application for my airline client. This started as a new reuseable Search Experience component, which can be dropped anywhere on the airline's website to funnel users into the search application.

Alongside this, my team and I have been working on replatforming the search application itself moving from a Javabased monolithic legacy application to a headless Next.js application using GraphQL, during which an interesting issue arose last week. Our application needs to access search parameters in the URL, on the serverside, to include an ESI in src/app/layout the root layout file of the application.

This should have been very straightforward it seems like a fairly basic use case, and Next provides an API function called useSearchParams which does exactly that. However, that only works on the client side, and as it turns out, there is quite literally no way to directly access search parameters in the root layout.


Understanding the Limitation

This seemed odd to me, but makes more sense with a little digging. Next.js follows a very specific architectural pattern which separates concerns and optimises performance.

Here are just a few of the reasons why accessing search parameters directly in the root layout is not supported:

Separation of Concerns

Layouts are meant for consistent parts of an application, such as headers, footers, and other structural components. Dynamic data fetching is typically handled in pages or API routes, making the flow of data more predictable and the codebase more maintainable.

In our case, the Header and Footer of the application are brought in via ESI, and need the search parameters to keep the search component (in the Header ESI) insync with the results we are displaying.

Performance Optimisation

Next.js reuses shared layouts during clientside navigation, keeping their state and avoiding unnecessary work. That also means a shared layout is not rerendered each time the query string changes; values captured during an earlier render can become stale.

Consistent Data Flow

Layouts can fetch data too. The distinction here is whether the value needs to change as the user navigates. For query state that must stay current, use the page's searchParams prop or useSearchParams in a Client Component, rather than relying on a previously rendered shared layout.


The Solution: Middleware and Request Headers

Given these limitations and our unusual set of requirements, we needed to find an alternative approach that allowed us to access search parameters on the serverside, whilst in the root layout. Over the course of many days, two members of our team struggled against this, quickly knocking one option after another out of the 'maybe' column.

In the end, our solution involved using Next.js middleware to capture the search parameters, forwarding them on the request. This internal request header can then be read by the layout during the same serverside render.

So, here's how we did it:

1. Capture the Search Parameters in the Middleware

The first step in this solution is to find a point, preclientside where we can access the search parameters. In Next.js this is middleware.ts, which allows us to run code whilst the request is being handled, so this is where we do our parametercapture and requestheader forwarding:

import { NextResponse, NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  const requestHeaders = new Headers(request.headers);
  const isSliceRequest = request.nextUrl.pathname.includes('/slice');

  // Never trust a client-supplied value for this internal header.
  requestHeaders.delete('x-search-params');

  if (isSliceRequest) {
    requestHeaders.set(
      'x-search-params',
      request.nextUrl.searchParams.toString(),
    );
  }

  return NextResponse.next({
    request: { headers: requestHeaders },
  });
}

export const config = {
  matcher: '/:path*',
};

Whenever a page within /slice is requested, middleware serialises the URL's existing search parameters into an internal request header called 'x-search-params'. It removes any clientsupplied value first, so the layout receives only the query string derived from this request.

If the URL does not contain /slice, the internal header remains absent, which prevents stale search data reaching the layout.

The important detail is that NextResponse.next forwards request headers to the route being rendered. A cookie set on the response would not appear in the cookie store for that same request.

2. A Utility Function to Extract the Search Parameters

The next step is to write a utility that converts the forwarded URLSearchParams value into a JavaScript object where each key is mapped to either a single string, or to an array of strings.

For this project, there are situations where a flight search might contain duplicate search parameters, for example, multiple dates for a return flight (one for the outbound and another for the return), or even more dates for multileg journeys covering more than one time zone. So, if a parameter appears more than once in the URL, then we aggregate it into an array attached to that key.

export const extractSearchParams = (
  searchParams: URLSearchParams
): { [key: string]: string | string[] } => {
  const searchParamsObject: { [key: string]: string | string[] } = Object.create(null);

  searchParams.forEach((value, key) => {
    if (Object.prototype.hasOwnProperty.call(searchParamsObject, key)) {
      if (Array.isArray(searchParamsObject[key])) {
        (searchParamsObject[key] as string[]).push(value);
      } else {
        searchParamsObject[key] = [searchParamsObject[key] as string, value];
      }
    } else {
      searchParamsObject[key] = value;
    }
  });

  return searchParamsObject;
};

This utility makes sure that all search parameters are captured accurately, and structured in a reliable way that can then be processed further. It falls outside the scope of this particular article, but the reason this is a reuseable utility rather than a singleuse function within layout is that we've since found other use cases for this functionality... and it's just a nicer way to structure our code. The object has no prototype, so query keys such as __proto__ are stored as ordinary own properties, including when they appear more than once.

3. Read the Search Parameters from the Request Header

Now we have the search parameters forwarded with the request and a utility that preserves repeated keys in the URLSearchParams value. The final part is reading that internal header on the server side in RootLayout (src/app/layout): This example uses the synchronous headers() API available in Next.js 14. Reading it opts the route into requesttime rendering; it does not make a shared layout rerender during ordinary clientside navigation.

import React from 'react';
import { headers } from 'next/headers';
import { extractSearchParams } from './utils/searchParams';

const getSearchParams = async (): Promise<{
  [key: string]: string | string[];
}> => {
  const searchParamsHeader = headers().get('x-search-params');

  return searchParamsHeader
    ? extractSearchParams(new URLSearchParams(searchParamsHeader))
    : {};
};

export default async function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const searchParams = await getSearchParams();

  return (
    <html lang='en' dir='ltr'>
      <body>
        <div>
          <h1>Search Parameters</h1>
          <pre>{JSON.stringify(searchParams, null, 2)}</pre>
        </div>
        {children}
      </body>
    </html>
  );
}

We're defining our RootLayout component, extracting search parameters from the internal request header when it exists, and displaying them on the server side.

The getSearchParams function reads the x-search-params request header set by middleware. We pass its URLSearchParams value through the extractSearchParams utility, then output the result inside <pre> tags whilst the page is serverrendered.

With this data now available within your RootLayout, you can do with it as you wish. For our project, we use it to structure an ESI tag, but for the sake of this example, it's just output onto the page.


Wrapping Up

To be honest, this feels a little hacky. It is a surprise that we've come across what feels like quite a rudimentary limitation in what is otherwise a very competent framework. Nevertheless, this is our solution and it's working well even in production.


Planning a platform change?

I help teams make difficult platform work clearer, from architecture decisions and migrations to launch recovery, performance, and search visibility.