URL as the Single Source of Truth in Next.js App Router
In the Next.js App Router, the URL becomes the definitive source of page state. Instead of synchronizing local useState with the address bar, the server-rendered page reads searchParams and immediately generates content. This eliminates inconsistencies between input fields, data, and navigation.
For product searches, this works like this: the URL contains ?q=phone → the server extracts the parameter → makes an API call → renders a ready-to-display list. Back/Forward navigation and direct links behave predictably.
Common Pitfalls of Client-Side Approaches
Traditional React code using useEffect creates multiple sources of truth:
"use client";
import { useEffect, useState } from "react";
export default function GoodsPage() {
const [q, setQ] = useState("");
const [items, setItems] = useState([]);
useEffect(() => {
async function load() {
const res = await fetch(`/api/goods?q=${encodeURIComponent(q)}`);
const data = await res.json();
setItems(data.products);
}
load();
}, [q]);
return (
<>
<input value={q} onChange={e => setQ(e.target.value)} />
<div>{items.map(item => <div key={item.id}>{item.title}</div>)}</div>
</>
);
}
Consequences:
- Direct links don’t restore state
- Page reloads reset data
- Back/Forward breaks UX
- Manual synchronization between input and URL is required
Server-Side Model: Data Before Render
Server Components read searchParams on the server and fetch data before hydration:
- The URL stores filtering parameters
- The server parses
searchParams - A fetch request runs with caching
- UI renders from pre-loaded props
A universal data layer for products:
// src/app/_data/dummyjson.js
const API_BASE = "https://dummyjson.com";
async function fetchJson(url, fetchOptions = {}) {
const res = await fetch(url, fetchOptions);
if (!res.ok) {
const text = await res.text().catch(() => "");
const err = new Error(`DummyJSON error: ${res.status} ${res.statusText}. ${text}`);
err.status = res.status;
throw err;
}
return res.json();
}
export async function getProducts({ q = "", limit = 12, skip = 0 } = {}) {
const safeQ = String(q).trim();
const qs = new URLSearchParams({
limit: String(limit),
skip: String(skip),
});
const url = safeQ
? `${API_BASE}/products/search?${qs.toString()}&q=${encodeURIComponent(safeQ)}`
: `${API_BASE}/products?${qs.toString()}`;
return fetchJson(url, {
next: { revalidate: 60 },
});
}
Search logic is isolated—page components stay clean and focused.
Implementing the Server-Side Page
// src/app/(app)/goods/page.js
import Link from "next/link";
import { getProducts } from "@/app/_data/goodsApi";
import GoodsSearchBar from "@/app/_ui/GoodsSearchBar";
import GoodsGridMotionClient from "@/app/_ui/GoodsGridMotionClient";
export default async function GoodsPage({ searchParams }) {
const sp = await searchParams;
const q = typeof sp?.q === "string" ? sp.q : "";
const data = await getProducts({ q, limit: 12, skip: 0 });
const isEmpty = !data?.products?.length;
const hasFilter = !!String(q || "").trim();
return (
<div>
<h1>Products</h1>
<GoodsSearchBar initialQuery={q} />
{isEmpty ? (
<div>
<h2>No results found</h2>
{hasFilter ? (
<p>There are no products matching "{q}"</p>
) : (
<p>The list is empty</p>
)}
<Link href="/goods">View all products</Link>
</div>
) : (
<GoodsGridMotionClient products={data.products} />
)}
</div>
);
}
The URL fully determines the page’s content.
Client-Side Search with URL Synchronization
The input remains client-side, but only updates the URL after submission:
"use client";
import { useEffect, useMemo, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
function getFirstQ(sp) {
const all = sp.getAll("q");
return all[0] ?? "";
}
export default function GoodsSearchBar({ basePath = "/goods" }) {
const router = useRouter();
const sp = useSearchParams();
const qFromUrl = getFirstQ(sp);
const [value, setValue] = useState(qFromUrl);
useEffect(() => {
setValue(qFromUrl);
}, [qFromUrl]);
function buildHref(nextQ) {
const q = nextQ.trim();
return q ? `${basePath}?q=${encodeURIComponent(q)}` : basePath;
}
function onSubmit(e) {
e.preventDefault();
router.push(buildHref(value));
}
function onReset() {
router.push(basePath);
}
const canSearch = useMemo(() => value.trim().length > 0, [value]);
return (
<form onSubmit={onSubmit}>
<input
value={value}
onChange={e => setValue(e.target.value)}
placeholder="e.g., phone"
/>
<button type="submit" disabled={!canSearch}>
Search
</button>
<button type="button" onClick={onReset}>
Reset
</button>
</form>
);
}
Key points:
useSearchParams()keeps the input in sync with browser historyrouter.push()changes the URL without triggering a client-side fetchgetFirstQ()handles duplicate query parameters
Benefits of This Architecture
- Single source of truth: The URL defines the page state
- Direct links:
/goods?q=phonealways returns the same result - Navigation: Back/Forward restores exact state
- Server rendering: Data loads before hydration
- Debugging: State is visible in the URL
When to Avoid Using URL Parameters
Good candidates for URL parameters:
- Search queries
- Sorting and filtering
- Pagination
- View modes
Better kept in client state:
- Dropdowns and modals
- Animations
- Temporary input
- Hover or focus states
Three Critical Mistakes to Avoid
- Dual state without synchronization: mixing
useStateand URL without coordination - Client-side fetch inside server components via
useEffect - Input as primary source instead of URL after form submission
What matters most:
- The URL should define page data in App Router
- Server components read
searchParamsbefore rendering - Client inputs sync with
useSearchParams() - Handle multiple parameters using
getAll()
— Editorial Team
No comments yet.