URL State with nuqs
Sync React state to URL query parameters for shareable filters, search queries, and deep links to modal dialogs. Preserves UI state on browser back/forward navigation.
Installation
bun add nuqsbun add nuqsSetup the Adapter
Wrap your app with the NuqsAdapter in your root layout:
import { NuqsAdapter } from "nuqs/adapters/next/app";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<NuqsAdapter>{children}</NuqsAdapter>
</body>
</html>
);
}import { NuqsAdapter } from "nuqs/adapters/next/app";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<NuqsAdapter>{children}</NuqsAdapter>
</body>
</html>
);
}Suspense Boundary Requirement
nuqs uses useSearchParams behind the scenes, which requires a Suspense boundary. Without one, Next.js throws an error during static rendering.
Wrap nuqs-using components with Suspense via a wrapper component. This keeps the Suspense boundary colocated with the component that needs it, avoiding the need to add Suspense in every consuming component:
import { Suspense } from "react";
type SearchInputProps = {
placeholder?: string;
};
// Public component with built-in Suspense
export function SearchInput(props: SearchInputProps) {
return (
<Suspense fallback={<input placeholder={props.placeholder} disabled />}>
<SearchInputClient {...props} />
</Suspense>
);
}import { Suspense } from "react";
type SearchInputProps = {
placeholder?: string;
};
// Public component with built-in Suspense
export function SearchInput(props: SearchInputProps) {
return (
<Suspense fallback={<input placeholder={props.placeholder} disabled />}>
<SearchInputClient {...props} />
</Suspense>
);
}"use client";
import { useQueryState, parseAsString } from "nuqs";
// Internal client component that uses nuqs
function SearchInputClient({ placeholder = "Search..." }: SearchInputProps) {
const [search, setSearch] = useQueryState("q", parseAsString.withDefault(""));
function handleChange(value: string) {
setSearch(value || null);
}
return (
<input
value={search}
onChange={(e) => handleChange(e.target.value)}
placeholder={placeholder}
/>
);
}"use client";
import { useQueryState, parseAsString } from "nuqs";
// Internal client component that uses nuqs
function SearchInputClient({ placeholder = "Search..." }: SearchInputProps) {
const [search, setSearch] = useQueryState("q", parseAsString.withDefault(""));
function handleChange(value: string) {
setSearch(value || null);
}
return (
<input
value={search}
onChange={(e) => handleChange(e.target.value)}
placeholder={placeholder}
/>
);
}Now SearchInput can be used anywhere without the consumer needing to add Suspense:
// No Suspense needed here - it's built into SearchInput
<SearchInput placeholder="Search recipes..." />// No Suspense needed here - it's built into SearchInput
<SearchInput placeholder="Search recipes..." />Basic Usage
Replace useState with useQueryState to sync state to the URL. This creates URLs like ?q=react when searching.
Parsers
nuqs provides parsers for different data types:
import {
useQueryState,
parseAsString,
parseAsBoolean,
parseAsArrayOf,
} from "nuqs";
// String with default
const [search, setSearch] = useQueryState("q", parseAsString.withDefault(""));
// Boolean (for toggles)
const [showArchived, setShowArchived] = useQueryState(
"archived",
parseAsBoolean.withDefault(false),
);
// Array of strings (for multi-select filters)
const [tags, setTags] = useQueryState(
"tags",
parseAsArrayOf(parseAsString).withDefault([]),
);import {
useQueryState,
parseAsString,
parseAsBoolean,
parseAsArrayOf,
} from "nuqs";
// String with default
const [search, setSearch] = useQueryState("q", parseAsString.withDefault(""));
// Boolean (for toggles)
const [showArchived, setShowArchived] = useQueryState(
"archived",
parseAsBoolean.withDefault(false),
);
// Array of strings (for multi-select filters)
const [tags, setTags] = useQueryState(
"tags",
parseAsArrayOf(parseAsString).withDefault([]),
);Deep Links to Modals
Use query params to control modal visibility, enabling shareable links. Apply the wrapper pattern to keep Suspense colocated:
import { Suspense } from "react";
type SettingsDialogProps = {
children: React.ReactNode;
};
// Public component with built-in Suspense
export function SettingsDialog(props: SettingsDialogProps) {
return (
<Suspense fallback={<span>{props.children}</span>}>
<SettingsDialogClient {...props} />
</Suspense>
);
}import { Suspense } from "react";
type SettingsDialogProps = {
children: React.ReactNode;
};
// Public component with built-in Suspense
export function SettingsDialog(props: SettingsDialogProps) {
return (
<Suspense fallback={<span>{props.children}</span>}>
<SettingsDialogClient {...props} />
</Suspense>
);
}"use client";
import { useQueryState, parseAsBoolean } from "nuqs";
import { Dialog, DialogTrigger, DialogContent } from "@/components/ui/dialog";
function SettingsDialogClient({ children }: SettingsDialogProps) {
const [isOpen, setIsOpen] = useQueryState(
"settings",
parseAsBoolean.withDefault(false),
);
return (
<Dialog open={isOpen} onOpenChange={(open) => setIsOpen(open || null)}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent>{/* Settings content */}</DialogContent>
</Dialog>
);
}"use client";
import { useQueryState, parseAsBoolean } from "nuqs";
import { Dialog, DialogTrigger, DialogContent } from "@/components/ui/dialog";
function SettingsDialogClient({ children }: SettingsDialogProps) {
const [isOpen, setIsOpen] = useQueryState(
"settings",
parseAsBoolean.withDefault(false),
);
return (
<Dialog open={isOpen} onOpenChange={(open) => setIsOpen(open || null)}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent>{/* Settings content */}</DialogContent>
</Dialog>
);
}Link directly to the open modal with ?settings=true.
For modals that operate on specific items (like delete confirmations), use the item ID:
const [deleteId, setDeleteId] = useQueryState("delete", parseAsString);
// Open: setDeleteId("item-123")
// Close: setDeleteId(null)
// Deep link: ?delete=item-123
<AlertDialog
open={!!deleteId}
onOpenChange={(open) => !open && setDeleteId(null)}
>const [deleteId, setDeleteId] = useQueryState("delete", parseAsString);
// Open: setDeleteId("item-123")
// Close: setDeleteId(null)
// Deep link: ?delete=item-123
<AlertDialog
open={!!deleteId}
onOpenChange={(open) => !open && setDeleteId(null)}
>SEO: Canonical URLs
Since query parameters are for local UI state, add a canonical URL to tell search engines to index the page without them:
import type { Metadata } from "next";
export const metadata: Metadata = {
alternates: {
canonical: "/",
},
};import type { Metadata } from "next";
export const metadata: Metadata = {
alternates: {
canonical: "/",
},
};This prevents duplicate content issues from filter variations like ?q=react and ?q=nextjs being indexed separately.
Clearing State
Setting a value to null removes it from the URL:
// Clear single param
setSearch(null);
// Clear multiple params
function clearFilters() {
setSearch(null);
setTags(null);
setShowArchived(null);
}// Clear single param
setSearch(null);
// Clear multiple params
function clearFilters() {
setSearch(null);
setTags(null);
setShowArchived(null);
}When using .withDefault(), setting to null clears the URL param but returns the default value as state.
Working with nuqs
Manage React state in URL query parameters with nuqs. Covers Suspense boundaries, parsers, clearing state, and deep-linkable dialogs.
Implement Working with nuqs
Manage React state in URL query parameters with nuqs for shareable filters, search, and deep-linkable dialogs.
See:
- Resource:
using-nuqsin Fullstack Recipes - URL: https://fullstackrecipes.com/recipes/using-nuqs
Suspense Boundary Pattern
nuqs uses useSearchParams behind the scenes, requiring a Suspense boundary. Wrap nuqs-using components with Suspense via a wrapper component to keep the boundary colocated:
import { Suspense } from "react";
type SearchInputProps = {
placeholder?: string;
};
// Public component with built-in Suspense
export function SearchInput(props: SearchInputProps) {
return (
<Suspense fallback={<input placeholder={props.placeholder} disabled />}>
<SearchInputClient {...props} />
</Suspense>
);
}import { Suspense } from "react";
type SearchInputProps = {
placeholder?: string;
};
// Public component with built-in Suspense
export function SearchInput(props: SearchInputProps) {
return (
<Suspense fallback={<input placeholder={props.placeholder} disabled />}>
<SearchInputClient {...props} />
</Suspense>
);
}"use client";
import { useQueryState, parseAsString } from "nuqs";
// Internal client component that uses nuqs
function SearchInputClient({ placeholder = "Search..." }: SearchInputProps) {
const [search, setSearch] = useQueryState("q", parseAsString.withDefault(""));
return (
<input
value={search}
onChange={(e) => setSearch(e.target.value || null)}
placeholder={placeholder}
/>
);
}"use client";
import { useQueryState, parseAsString } from "nuqs";
// Internal client component that uses nuqs
function SearchInputClient({ placeholder = "Search..." }: SearchInputProps) {
const [search, setSearch] = useQueryState("q", parseAsString.withDefault(""));
return (
<input
value={search}
onChange={(e) => setSearch(e.target.value || null)}
placeholder={placeholder}
/>
);
}This pattern allows consuming components to use SearchInput without adding Suspense themselves.
State to URL Query Params
Replace useState with useQueryState to sync state to the URL:
"use client";
import {
useQueryState,
parseAsString,
parseAsBoolean,
parseAsArrayOf,
} from "nuqs";
// String state (search, filters)
const [search, setSearch] = useQueryState("q", parseAsString.withDefault(""));
// Boolean state (toggles)
const [showArchived, setShowArchived] = useQueryState(
"archived",
parseAsBoolean.withDefault(false),
);
// Array state (multi-select)
const [tags, setTags] = useQueryState(
"tags",
parseAsArrayOf(parseAsString).withDefault([]),
);"use client";
import {
useQueryState,
parseAsString,
parseAsBoolean,
parseAsArrayOf,
} from "nuqs";
// String state (search, filters)
const [search, setSearch] = useQueryState("q", parseAsString.withDefault(""));
// Boolean state (toggles)
const [showArchived, setShowArchived] = useQueryState(
"archived",
parseAsBoolean.withDefault(false),
);
// Array state (multi-select)
const [tags, setTags] = useQueryState(
"tags",
parseAsArrayOf(parseAsString).withDefault([]),
);Clear State
Set to null to remove from URL:
// Clear single param
setSearch(null);
// Clear all filters
function clearFilters() {
setSearch(null);
setTags(null);
setShowArchived(null);
}// Clear single param
setSearch(null);
// Clear all filters
function clearFilters() {
setSearch(null);
setTags(null);
setShowArchived(null);
}When using .withDefault(), setting to null clears the URL param but returns the default value.
Deep-Linkable Dialogs
Control dialog visibility with URL params for shareable links:
import { Suspense } from "react";
type DeleteDialogProps = {
onDelete: (id: string) => Promise<void>;
};
// Public component with built-in Suspense
export function DeleteDialog(props: DeleteDialogProps) {
return (
<Suspense fallback={null}>
<DeleteDialogClient {...props} />
</Suspense>
);
}import { Suspense } from "react";
type DeleteDialogProps = {
onDelete: (id: string) => Promise<void>;
};
// Public component with built-in Suspense
export function DeleteDialog(props: DeleteDialogProps) {
return (
<Suspense fallback={null}>
<DeleteDialogClient {...props} />
</Suspense>
);
}"use client";
import { useQueryState, parseAsString } from "nuqs";
import { AlertDialog, AlertDialogContent } from "@/components/ui/alert-dialog";
function DeleteDialogClient({ onDelete }: DeleteDialogProps) {
const [deleteId, setDeleteId] = useQueryState("delete", parseAsString);
async function handleDelete() {
if (!deleteId) return;
await onDelete(deleteId);
setDeleteId(null);
}
return (
<AlertDialog open={!!deleteId} onOpenChange={(open) => !open && setDeleteId(null)}>
<AlertDialogContent>
{/* Confirmation UI */}
<Button onClick={handleDelete}>Delete</Button>
</AlertDialogContent>
</AlertDialog>
);
}"use client";
import { useQueryState, parseAsString } from "nuqs";
import { AlertDialog, AlertDialogContent } from "@/components/ui/alert-dialog";
function DeleteDialogClient({ onDelete }: DeleteDialogProps) {
const [deleteId, setDeleteId] = useQueryState("delete", parseAsString);
async function handleDelete() {
if (!deleteId) return;
await onDelete(deleteId);
setDeleteId(null);
}
return (
<AlertDialog open={!!deleteId} onOpenChange={(open) => !open && setDeleteId(null)}>
<AlertDialogContent>
{/* Confirmation UI */}
<Button onClick={handleDelete}>Delete</Button>
</AlertDialogContent>
</AlertDialog>
);
}Open the dialog programmatically:
// Open delete dialog for specific item
setDeleteId("item-123");
// Deep link: /items?delete=item-123// Open delete dialog for specific item
setDeleteId("item-123");
// Deep link: /items?delete=item-123Opening Dialogs from Buttons
Use a trigger button to open the dialog:
function ItemRow({ item }: { item: Item }) {
const [, setDeleteId] = useQueryState("delete", parseAsString);
return (
<Button variant="ghost" onClick={() => setDeleteId(item.id)}>
Delete
</Button>
);
}function ItemRow({ item }: { item: Item }) {
const [, setDeleteId] = useQueryState("delete", parseAsString);
return (
<Button variant="ghost" onClick={() => setDeleteId(item.id)}>
Delete
</Button>
);
}