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);