Access Search Parameters in Next.js SSR'd Layout

Hero image for Access Search Parameters in Next.js SSR'd Layout.
Hero image for 'Access Search Parameters in Next.js SSR'd Layout.'

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

Static generation and Incremental Static Regeneration (ISR) are key features of Next.js which help to optimise the application performance. By keeping layouts static, Next.js can cache them across different pages, enhancing the overall performance.

Consistent Data Flow

Under this pattern, dynamic data is fetched at the page level which ensures a clear separation of static and dynamic content. Complex data fetching and transformations are handled through API routes and middleware, maintaining modularity.


The Solution: Middleware and Cookies

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, storing them in a cookie. This same cookie can then be accessed in the layout at load time and during serverside rendering.

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 while the request is being handled, so this is where we do our parametercapture and cookie setting:

import { NextResponse, NextRequest } from 'next/server';import { extractSearchParams } from './utils/searchParams';export async function middleware(request: NextRequest) {  const {    nextUrl,    nextUrl: { pathname },  } = request;  const sliceFragment = '/slice';  // Capture search params and push them into a cookie  if (pathname.includes(sliceFragment)) {    const searchParams = request.nextUrl.searchParams;    const searchParamsObject = extractSearchParams(searchParams);    const searchParamsJson = JSON.stringify(searchParamsObject);    const response = NextResponse.next();    response.cookies.set('search-params', searchParamsJson);    return response;  } else {    // Reset the cookie if it exists    const response = NextResponse.next();    if (request.cookies.has('search-params')) {      response.cookies.delete('search-params');    }    return response;  }  return NextResponse.next();}export const config = {  matcher: '/:path*',};

What we're doing here is triggering the search parameters fetch whenever a page within /slice is requested. We capture the search parameters directly from the request URL and process them into an object. This is then stored into a cookie called 'searchparams'.

If the URL does not contain /slice, then we check for the cookie and if it exists delete it again so that we can prevent stale content.

The important thing to bear in mind here is that the cookie is available immediately as the request is being processed and can then be picked up within our server rendering.

2. A Utility Function to Extract the Search Parameters

The next step is to write a utility that takes the URLSearchParams object we've just placed into a cookie, and convert it 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[] } = {};  searchParams.forEach((value, key) => {    if (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.

Now, we have the search parameters stored in a cookie, and a utility function we can use to convert the stored URLSearchParams object into something a little more useable. The final part of the puzzle is reading the cookie on the serverside in RootLayout (src/app/layout):

import React from 'react';import { cookies } from 'next/headers';import { extractSearchParams } from './utils/searchParams';const getSearchParams = async (): Promise<{  [key: string]: string | string[];}> => {  const cookieStore = cookies();  const searchParamsCookie = cookieStore.get('search-params');  return searchParamsCookie    ? extractSearchParams(        new URLSearchParams(JSON.parse(searchParamsCookie.value))      )    : {};};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>  );}

What we're doing here is defining our RootLayout component., extracting search parameters from a cookie (if it exists) and displays them on the server side.

The getSearchParams function retrieves and parses the searchparams cookie that we've set in the middleware. We then pass this through the extractSearchParams utility to transform the search parameter data, and output that into the page inside <pre> tags, all done whilst the page is being SSR'd.

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.


Categories:

  1. Development
  2. Front‑End Development
  3. JavaScript
  4. Next.js
  5. Server‑Side Rendering