
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 Java‑based 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 server‑side, 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) in‑sync 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 server‑side, 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 server‑side 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, pre‑client‑side 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 parameter‑capture 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 'search‑params'.
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 multi‑leg 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 single‑use 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.
3. Read the Search Parameters Back Out of the Cookie
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 server‑side 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 search‑params 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.
Related Articles

Flattening Arrays in JavaScript. Get the Number of Years Between Two Dates with PHP and JavaScript. Get the Number of Years Between Two Dates with PHP and JavaScript

Understanding the Backtracking Approach: Solving the 'Word Search' Problem. Understanding the Backtracking Approach: Solving the 'Word Search' Problem

Understanding Phantom window.resize Events in iOS. Understanding Phantom
window.resizeEvents in iOS
Solving the LeetCode Two Sum Problem Using JavaScript. Solving the LeetCode Two Sum Problem Using JavaScript

String to Integer (atoi): Decoding Strings in JavaScript. String to Integer (atoi): Decoding Strings in JavaScript

Reverse an Array in JavaScript. Reverse an Array in JavaScript

Pure Functions in JavaScript. Pure Functions in JavaScript

Rendering Contentful Rich Code Snippets in Gatsby. Rendering Contentful Rich Code Snippets in Gatsby

Testing Vue Components with Vue Test Utils. Testing Vue Components with Vue Test Utils

The Execution Context in JavaScript. The Execution Context in JavaScript

Building Polyfills for JavaScript Array and String Methods. Building Polyfills for JavaScript Array and String Methods