State URL có kiểu cho React & Next.js — như useState
useUrlState là state React tự ghi chính nó vào chuỗi truy vấn. Object, array và ngày giữ nguyên kiểu, mọi state là một liên kết có thể chia sẻ, tồn tại qua các lần tải lại và nút quay lại hoạt động — không provider, không ranh giới Suspense, không boilerplate.
- ~2 KB nén gzip
- không phụ thuộc
- TypeScript-first
- Next.js / react-router / Remix / Astro
- MIT
npm i state-in-urlQuản lý state URL trong Next.js App Router
state-in-url giữ state có kiểu trong chuỗi truy vấn trên Next.js 14, 15 và 16: một hook useUrlState cho mỗi tính năng, không adapter, không provider, không ranh giới Suspense. Trang này nói về những gì đặc thù của App Router — Server Components, prerendering, layout và history.
Demo trực tiếp trên trang chủ chạy trên Next.js 16.
Chuyển tiếp searchParams từ trang server
Một trang Server Component nhận searchParams — là Promise kể từ Next.js 15. Await nó rồi truyền object vào client component, component này đưa nó cho hook. Lần render đầu trên server khi đó hiển thị giá trị của URL thay vì giá trị mặc định, nên không có nhấp nháy và không có cảnh báo hydration.
// app/jobs/page.tsx (Server Component)
import { JobsList } from './JobsList';
export default async function Page({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
// A Promise since Next.js 15; a plain object in 14
return <JobsList searchParams={await searchParams} />;
}// app/jobs/JobsList.tsx
'use client';
import { useUrlState } from 'state-in-url/next';
// Outside the component: sharing is keyed by object identity
const JOBS_STATE = { q: '', page: 1, remote: false, tags: [] as string[] };
export function JobsList({ searchParams }: { searchParams: object }) {
const { urlState, setUrl } = useUrlState(JOBS_STATE, { searchParams });
return (
<input
value={urlState.q}
onChange={(ev) => setUrl({ q: ev.target.value, page: 1 })}
/>
);
}Không ranh giới Suspense, vẫn prerender
Hook không bao giờ gọi useSearchParams, nên component dùng nó không cần bọc trong Suspense và không khiến trang bị loại khỏi prerendering — bao gồm PPR và cacheComponents . Nó đọc URL trực tiếp và theo dõi mọi thay đổi sau đó, bao gồm cả history.pushState từ code không biết gì về nó. Trang được prerender vẫn render giá trị mặc định, vì lúc build không có chuỗi truy vấn — hãy render route đó động khi một liên kết chia sẻ phải đúng ngay từ lần vẽ đầu tiên.
Layout: giải mã chuỗi truy vấn từ một header
Layout trên server không bao giờ nhận searchParams. Sao chép chuỗi truy vấn vào một header của request trong proxy.ts (middleware.ts vẫn hoạt động như một alias đã lỗi thời) và giải mã nó trong layout bằng decodeState cùng chính object state mặc định đó — kết quả có kiểu y hệt urlState phía client.
// proxy.ts (middleware.ts before Next.js 16)
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
export function proxy(request: NextRequest) {
const sp = (request.url.includes('_next') ? '' : request.url).split('?')[1] ?? '';
const headers = new Headers(request.headers);
headers.set('searchParams', sp);
return NextResponse.next({ request: { headers } });
}// app/jobs/layout.tsx (Server Component)
import { headers } from 'next/headers';
import { decodeState } from 'state-in-url/encodeState';
import { JOBS_STATE } from './jobsState';
export default async function Layout({ children }: { children: React.ReactNode }) {
const sp = (await headers()).get('searchParams') ?? '';
const initial = decodeState(sp, JOBS_STATE); // typed like urlState
return <>{/* use `initial` */}{children}</>;
}History, cập nhật shallow và scroll
setUrl mặc định thay thế mục history hiện tại, nên gõ phím không chất đống mục; truyền replace: false để push thêm một mục. Cập nhật đi qua History API — không có vòng đi-về server và không request _rsc cho mỗi phím gõ. Truyền useHistory: false để đi qua router của Next.js thay thế, khi server cần render lại ở mỗi thay đổi. scroll mặc định là false.
Ô nhập nhanh: render ngay, ghi URL sau
Với ô văn bản và thanh trượt, cập nhật bằng setState ở mỗi thay đổi và gọi setUrl() không tham số khi blur hoặc sau một debounce. Component render lại ngay lập tức; URL được ghi một lần, có so sánh theo nội dung, nên gọi lặp lại là an toàn.
const { urlState, setState, setUrl } = useJobsState();
// Render now, write the URL once the field is left
<input
value={urlState.q}
onChange={(ev) => setState({ q: ev.target.value })}
onBlur={() => setUrl()}
/>
setUrl({ page: 2 }); // replaces the history entry (default)
setUrl({ page: 2 }, { replace: false }); // pushes a new one — Back returns to page 1
setUrl({ page: 2 }, { scroll: true }); // scroll to top, off by defaultĐọc bản so sánh đầy đủ — cùng một tính năng viết bằng cả hai, và cách di chuyển
State URL trong Next.js — câu hỏi thường gặp
- Làm sao giữ state trong URL với Next.js App Router?
- Định nghĩa một object state mặc định bên ngoài component, bọc useUrlState từ state-in-url/next trong một hook nhỏ, và gọi hook đó trong bất kỳ client component nào. urlState là giá trị hiện tại có kiểu và setUrl ghi một partial vào chuỗi truy vấn. Truyền prop searchParams của trang vào để lần render trên server đã đúng sẵn.
- useSearchParams có cần ranh giới Suspense không, còn state-in-url thì sao?
- useSearchParams của Next đưa một route render tĩnh sang render phía client cho đến ranh giới Suspense gần nhất, và build sẽ thất bại nếu không có ranh giới đó. state-in-url không bao giờ gọi nó: hook đọc searchParams trên server và window.location phía client, nên không cần ranh giới nào và prerendering, kể cả PPR, được giữ nguyên.
- Đọc state URL trong Server Component như thế nào?
- Trang nhận nó qua prop searchParams — await rồi hoặc chuyển tiếp cho hook phía client, hoặc giải mã ngay trên server bằng decodeState cùng chính object mặc định đó. Layout không nhận searchParams; hãy đưa chuỗi truy vấn ra qua một header đặt trong proxy.ts và giải mã header đó.
- Cập nhật URL có khiến trang render lại trên server không?
- Mặc định là không. setUrl cập nhật qua History API, nên không có gì được fetch và không có request _rsc nào. Khi server cần thấy state mới — chẳng hạn để fetch lại một danh sách trong Server Component — hãy truyền useHistory: false để cập nhật đi qua router của Next.js và route được render lại.
- state-in-url có phải lựa chọn thay thế nuqs cho Next.js không?
- Có. Cả hai đều giữ state có kiểu trong chuỗi truy vấn; state-in-url nhận một object với giá trị lồng nhau và ngày tháng được giữ nguyên, không cần component adapter hay parser theo khóa, và không bao giờ chạm vào useSearchParams. nuqs hợp hơn khi mỗi giá trị nên là một query param dễ đọc bằng mắt. Xem bản so sánh đầy đủ.
- Những phiên bản Next.js nào được hỗ trợ?
- Next.js 14, 15 và 16 trên App Router, bao gồm searchParams bất đồng bộ ra mắt ở bản 15 và cacheComponents với PPR ở bản 16. Các thiết lập khác có thể dùng các helper encodeState và decodeState không phụ thuộc framework với router mà mình chọn.
