Type-safe base-64 encoded search params: integrating Nuqs with Zod


In a recent project, we needed to create a wizard component whose state is shared via the URL for seamless navigation and sharing between users. In this post, I’ll walk through how to store that state in the URL in a type-safe, encoded way, using much simpler example of a shopping cart.

Why does is matter?

It allows users to share specific application states simply by sharing a link, ensuring others can view the same information or settings. It also enables users to return to the same state after a page reload or navigating away. Without proper state management in the URL, this process becomes error-prone, especially for dynamic and complex application states, leading to inconsistent behavior and difficulties in maintaining state across different sessions or components.

The solution

In the first part of the code, we define the Zod schema for the shopping cart state (CartStateSchema). This schema validates the cart’s items and discount code. We also define the function parseAsCompressedJson to handle parsing and serializing the cart state. It ensures that the cart state is compressed and decompressed safely while being passed as URL parameters.

import { createParser } from "nuqs";
import { createSearchParamsCache, createSerializer } from "nuqs/server";
import { z } from "zod";

export const CartStateSchema = z.object({
items: z.array(
z.object({
productId: z.number(),
quantity: z.number().min(1),
price: z.number(),
})
).optional(),
discountCode: z.string().optional(),
});

export type CartState = z.infer<typeof CartStateSchema>;

const parseAsCompressedJson = <T>(
zodSchema: z.ZodType<T, any>,
) =>
createParser({
parse: (queryValue) => {
const originalString = toOriginalString(queryValue);

if (!originalString || typeof originalString !== "string") {
return null;
}

const parsed = zodSchema.safeParse(JSON.parse(originalString));
return parsed.success ? parsed.data : null;
},
serialize: (value) => {
const parsed = zodSchema.parse(value);

const jsonString = JSON.stringify(parsed);
return toCompressedString(jsonString);
},
});

export const cartSearchParams = {
cartState: parseAsCompressedJson(CartStateSchema),
} as const;

export type CartSearchParams = typeof cartSearchParams;

In the second part, we implement two helper functions (toCompressedString and toOriginalString) that handle the compression and decompression of the cart state. These functions use pako for compression and url-safe-base64 encoding/decoding to ensure that the data can be safely stored and transmitted as URL parameters. The toCompressedString function compresses the data and converts it to a URL-safe format, while toOriginalString decompresses and decodes it back to the original JSON string.

import { deflate, inflate } from "pako";
import { decode, encode } from "url-safe-base64";

const toCompressedString = (string: string) => {
const compressedAsUnit8Array = deflate(string);
const compressedAsString = String.fromCharCode(...compressedAsUnit8Array);
const compressedAsStandardBase64 = btoa(compressedAsString);
const compressedAsUrlSafeBase64 = encode(compressedAsStandardBase64);
return compressedAsUrlSafeBase64;
};

const toOriginalString = (urlString: string) => {
const compressedAsUrlSafeBase64 = urlString;
const compressedAsStandardBase64 = decode(compressedAsUrlSafeBase64);
const compressedAsString = atob(compressedAsStandardBase64);
const compressedAsUnit8Array = new Uint8Array(
compressedAsString.split("").map((char) => char.charCodeAt(0)),
);
const jsonString = inflate(compressedAsUnit8Array, { to: "string" });
return jsonString;
};

export const cartSearchParamsCache = createSearchParamsCache(cartSearchParams);
export const serializeCartSearchParams = createSerializer(cartSearchParams);

Turbo-charged trouble: Next.js 14.2, Turbo, and Sentry causing HMR problems

Recently, I ran into a frustrating issue while experimenting with Turbo in a Next.js 14.2 project. We were eager to try Turbo to improve developer experience (DX), but our excitement quickly faded when we noticed that HMR wasn’t working reliably. About half the time, making a change would cause the app to crash.

The Problem

When we made code edits, updated environment variables, or triggered HMR, our app would crash with an error like this:

Error: Module [project]/node_modules/next/dist/esm/client/components/error-boundary.js was instantiated because it was required from module…

Why (we think) it happened?

This crash happened because Turbo’s HMR system and Sentry’s instrumentation were conflicting. Turbo reloads code differently than Webpack, and Sentry’s setup wasn’t fully compatible with it yet.

The Fix: Adjusting Instrumentation for Turbo

To fix the issue, you need to modify your instrumentation setup so Sentry doesn’t load when Turbo is active.

Add a check for the TURBOPACK environment variable to skip Sentry’s configuration when Turbo is enabled:

// next.config.js
if (!process.env.TURBOPACK) {
const { withSentryConfig } = require('@sentry/nextjs');
module.exports = withSentryConfig(module.exports, { });
}

In your instrumentation.ts file, add logic to skip loading Sentry’s instrumentation if Turbo is active:


if (process.env.TURBOPACK) {
return;
}


When to Use Search Params vs Path Params in Next.js

One of the key decisions developers face when building modern web applications is whether how to store state in the URL. Once you’ve decided to store state in the URL, the next step is choosing the right format for your parameters: search params (query params) or path params. Both options offer different advantages depending on the context of your application. Understanding when to use each can make your URL structure more intuitive, flexible, and user-friendly.

When to Use Search Params

Search parameters are ideal for managing temporary, session-based state or user interactions that don’t need to be a permanent part of the URL structure. They are best suited for things like filtering, sorting, pagination, and tracking temporary states within a session.

For example, if you have a product listing page where users can filter products by category, sort them by price, or paginate through results, these types of states are typically stored in the URL as search parameters. A user can share a URL with the exact filter settings or return to the same page after refreshing, making it easy to track and manipulate these settings without affecting the core structure of the application.

Search parameters work especially well for scenarios where the state is ephemeral and should not define a specific resource or entity. In these cases, the state is a part of the user’s interaction with the page, rather than the page itself.

Another common use of search parameters is session tracking or capturing temporary user preferences. For example, in a multi-step form or wizard-like interface, search parameters can store the current step or the state of the form without requiring a full page reload or altering the URL path.

When to Use Path Params

Path parameters are better suited for situations where the state is tied to a specific resource or unique entity in your application. These parameters are part of the URL structure itself, representing a unique resource such as a product, user profile, or a specific blog post.

If your app has dynamic routes, where the content being displayed corresponds to an entity or resource, path parameters are the natural choice. For instance, a product detail page might use a path parameter like /products/[id] to dynamically load information about a specific product based on its unique identifier. Similarly, user profiles often use path parameters like /user/[username] to load a specific user’s data.

Path parameters are also helpful when defining a hierarchical structure in your application. For example, in a blog system, the URL might be /blog/[category]/[slug], where the category defines the broader context (e.g., „technology”) and the slug identifies the specific article. This creates a clean, SEO-friendly URL that represents the content’s place within the app’s structure.

Path Params for SEO and Clean Structure

One of the strongest advantages of path parameters is their ability to maintain a clean and semantic URL structure. URLs that use path parameters are easy to understand, share, and index by search engines. They reflect the inherent structure of the application, making them ideal for resources that should be treated as distinct entities. For instance, a URL like /products/123 is intuitive and represents a unique product, making it more accessible for both users and search engines alike.

Combining Search and Path Params

In some cases, you might find that combining both search and path parameters in the same application is beneficial. For example, you could use path parameters to define the resource (like /products/[id]) and search parameters to manage filtering or sorting (/products/[id]?color=red&sort=price). This approach allows you to maintain a clean URL structure for the resource while still capturing temporary state information.

Conclusion

Deciding between search and path parameters depends on the nature of the state you’re managing in your Next.js application. Use search parameters when dealing with temporary, session-based data like filters, sorting, or pagination—anything that doesn’t define a unique resource. On the other hand, use path parameters when the state corresponds to a specific, permanent resource, such as products, user profiles, or blog posts.

By understanding the strengths of each approach, you can design URLs that are intuitive, flexible, and easy to manage, improving both the user experience and the maintainability of your Next.js application.