Back to Home

URL as the source of state Next.js App Router

In Next.js App Router URL becomes the single source of page state. Server components read searchParams and form content before rendering. Client form synchronizes with browser history without extra useEffect.

URL controls data in Next.js App Router
Advertisement 728x90

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:

Google AdInline article slot
"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:

Google AdInline article slot
// 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:

Google AdInline article slot
"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 history
  • router.push() changes the URL without triggering a client-side fetch
  • getFirstQ() handles duplicate query parameters

Benefits of This Architecture

  • Single source of truth: The URL defines the page state
  • Direct links: /goods?q=phone always 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 useState and 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 searchParams before rendering
  • Client inputs sync with useSearchParams()
  • Handle multiple parameters using getAll()

— Editorial Team

Advertisement 728x90

Read Next