This is to explore how we can combine client and server components for better performance. We will use a search bar functionality utilizing URL search params (not state) in an app as an example case.
Code
// Page component
import Pagination from "@/app/ui/invoices/pagination";
import Search from "@/app/ui/search";
import Table from "@/app/ui/invoices/table";
import { CreateInvoice } from "@/app/ui/invoices/buttons";
import { lusitana } from "@/app/ui/fonts";
import { InvoicesTableSkeleton } from "@/app/ui/skeletons";
import { Suspense } from "react";
export default async function Page(props: {
searchParams?: Promise<{
query?: string;
page?: string;
}>;
}) {
const searchParams = await props.searchParams;
const query = searchParams?.query || "";
const currentPage = Number(searchParams?.page) || 1;
return (
<div className="w-full">
<div className="flex w-full items-center justify-between">
<h1 className={`${lusitana.className} text-2xl`}>Invoices</h1>
</div>
<div className="mt-4 flex items-center justify-between gap-2 md:mt-8">
<Search placeholder="Search invoices..." />
<CreateInvoice />
</div>
<Suspense key={query + currentPage} fallback={<InvoicesTableSkeleton />}>
<Table query={query} currentPage={currentPage} />
</Suspense>
<div className="mt-5 flex w-full justify-center">
{/* <Pagination totalPages={totalPages} /> */}
</div>
</div>
);
}
// Search component
"use client";
import { MagnifyingGlassIcon } from "@heroicons/react/24/outline";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useDebouncedCallback } from "use-debounce";
export default function Search({ placeholder }: { placeholder: string }) {
const searchParams = useSearchParams();
const pathname = usePathname();
const { replace } = useRouter();
const handleSearch = useDebouncedCallback((term: string) => {
const params = new URLSearchParams(searchParams);
if (term) {
params.set("query", term);
} else {
params.delete("query");
}
replace(`${pathname}?${params.toString()}`);
}, 300);
return (
<div className="relative flex flex-1 flex-shrink-0">
<label htmlFor="search" className="sr-only">
Search
</label>
<input
className="peer block w-full rounded-md border border-gray-200 py-[9px] pl-10 text-sm outline-2 placeholder:text-gray-500"
placeholder={placeholder}
onChange={(e) => handleSearch(e.target.value)}
defaultValue={searchParams.get("query")?.toString()}
/>
<MagnifyingGlassIcon className="absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500 peer-focus:text-gray-900" />
</div>
);
}
// InvoicesTable component
import Image from 'next/image';
import { UpdateInvoice, DeleteInvoice } from '@/app/ui/invoices/buttons';
import InvoiceStatus from '@/app/ui/invoices/status';
import { formatDateToLocal, formatCurrency } from '@/app/lib/utils';
import { fetchFilteredInvoices } from '@/app/lib/data';
export default async function InvoicesTable({
query,
currentPage,
}: {
query: string;
currentPage: number;
}) {
const invoices = await fetchFilteredInvoices(query, currentPage);
return (
<div className="mt-6 flow-root">
<div className="inline-block min-w-full align-middle">
<div className="rounded-lg bg-gray-50 p-2 md:pt-0">
<div className="md:hidden">
{invoices?.map((invoice) => (
<div
key={invoice.id}
className="mb-2 w-full rounded-md bg-white p-4"
>
. . .
How this search bar functionality with URL search params works
The key to understanding what's happening is how Next.js App Router handles URL search parameters and passes them to page components.
The overview of the complete flow looks like this:
1. User types in search box
↓
2. Search component calls handleSearch()
↓
3. URL gets updated to /dashboard/invoices?query=searchTerm
↓
4. Next.js detects URL change, re-renders Page component with new searchParams
↓
5. Page component extracts query and currentPage
↓
6. These get passed to Table component
↓
7. Table component fetches filtered data
Here is the breakdown of each step.
-1. User types in search box
Nothing to say here. Just let them type whatever!
-2. Search component calls handleSearch()
The handleSearch
function in the Search
component gets called, creates searchParams for the URL and updates it.
const handleSearch = useDebouncedCallback((term: string) => {
// Creates a new URLSearchParams object
const params = new URLSearchParams(searchParams);
// Sets ?query=searchTerm or delete the parameter
if (term) {
params.set("query", term);
} else {
params.delete("query");
}
// Updates the URL without a page refresh
replace(`${pathname}?${params.toString()}`);
}, 300);
-3. URL gets updated to /dashboard/invoices?query=searchTerm
Thanks to the previous step!
-4. Next.js detects URL change, re-renders Page component with new searchParams
This occurs without a full page reload thanks to the Next.js App Router feature.
-5. Page component extracts query and currentPage
This is the Next.js App Router's nature. When we have a Page
component, Next.js App Router automatically passes certain props to it based on the URL and route structure. This includes searchParams
.
SearchParams has a JavaScript object-like structure.
E.g.) the search params for the URL /dashboard/invoices?query=john&page=2
would look like this: {query: 'john', page: '2'}
.
Here, Next.js automatically extracts the search parameters from the URL and passes them to the page component as the searchParams prop.
When the URL is /dashboard/invoices?query=john&page=2
, the extracted search params will look like this:
{
searchParams: Promise<{
query: "john",
page: "2"
}>
}
-6. These get passed to Table component in the Page component
const searchParams = await props.searchParams;
const query = searchParams?.query || "";
const currentPage = Number(searchParams?.page) || 1;
return (
. . .
<Table query={query} currentPage={currentPage} />
. . .
)
-7. Table component fetches filtered data
It calls fetchFilteredInvoices()
function passing query and currentPage as the arguments.
Side Note
Notice that the Search component is a client component, whereas the Page and the Table components are server components. The Search component has to interact with the user input and manipulate the URL, so it must be a client component.
Notice that, in order to extract the URL search parameters, we use
useSearchParams()
in the Search component (which is a client component) because accessing the URL directly with this method on the client is faster than waiting for Next.js to send the info from the server to the client.
// if it's on the client
const searchParams = useSearchParams();
const query = searchParams.get('query'); // <- Instant, no network ◎
// if it's on the server
const { searchParams } = props;
const query = searchParams?.query; // <- Needs server rendering △
- Notice that, in order to fetch the data and show the table according to the URL search parameters, we use
searchParams
prop in the Page component (which is a server component) and then pass them to the Table component (also a server component) because this way takes fewer network trips than doing everything from the client.
// if it's on the client
"use client";
function ClientComponent () {
const searchParams = useSearchParams();
const query = searchParams.get('query');
const [data, setData] = useState(null);
useEffect(() => {
fetch(`/api/invoices?query=${query}`)
.then(res => res.json())
.then(setData);
}, [query]);
return <div>{data?.map(...)}</div>;
}
// ^ Needs to call the data fetching API using the search params AFTER loading the page structure (empty placeholder) with JS code, resulting in double network trips △
// if it's on the server
async function ServerComponent ({ searchParams }) {
const query = searchParams?.query;
const data = await fetchFilteredInvoices(query); // <- Direct DB access
return <div>{data.map(...)}</div>;
}
// ^ This approach can get the page structure AND the data at the same time, resulting in only one network trip ◎
// Server does this BEFORE sending a response on the first request:
// 1. Next.js extracts searchParams from URL
// 2. Page component receives searchParams
// 3. Table component gets query prop
// 4. fetchFilteredInvoices() runs on server
// 5. Server gets data from database
// 6. Server renders complete HTML with data
- The key difference is when the URL search parameters are available.
Client-Side Flow:
1. Server sends HTML (searchParams not available yet)
2. JavaScript loads in browser
3. useSearchParams() can now read URL
4. Now make API request with parameters
visualized:
Browser Server
│ │
│ ──── GET /invoices?query=john ───> │
│ │ (can't access searchParams until JS runs in browser)
│ <────── HTML + JS (no data) ────── │
│ │
│ (JS runs, reads URL) │
│ │
│ ── GET /api/invoices?query=john ─→ │
│ <────────── JSON data ──────────── │
Server-Side Flow:
1. Next.js extracts searchParams from URL immediately
2. Server components can use searchParams right away
3. Server fetches data and renders complete HTML
4. Send everything together
visualized:
Browser Server
│ │
│ ── GET /invoices?query=john ─> │
│ │ (Next.js immediately extracts searchParams)
│ │ ↓
│ │ fetchFilteredInvoices(query)
│ │ ↓
│ <─ Complete HTML with data ─── │
References:
Learn Next.js Chapter 11
Top comments (0)