Skip to content

The Server Writes the Title, the Client Fills the List

Adityo Guni Waluyo

Server shell, metadata and heading on the server; tabs, a 350 ms debounced search, and the board detail live in one client island.

TL;DR

The page title and metadata are server-rendered. The list is fetched in the browser when the component mounts. Server writes metadata and heading, browser fetches data.

I opened the freshly built /informasi/olahraga page: the title "Informasi Olahraga — Cabang Olahraga & Kelembagaan" was already there, six grey boxes shimmered underneath, and after that the organization cards filled in one by one. The title arrived first because the server wrote it; the list followed from the browser. Commit 368d0e2.

My first guess was that this page was server-rendered end to end like the other pages in the project: the list, the board members, everything coming out in a single HTML response. The code says otherwise. The server only writes the metadata, the heading, and the fallback frame; not one data row ever travels through it, everything is fetched in the browser when the component mounts.

Server Shell, Client Contents

page.tsx starts with export const dynamic = "force-dynamic", then generateMetadata() fills in the page title and description. Next.js turns that metadata into the page's <head> tags [4], so the part search engines care about is produced on the server without a single line of client JavaScript. The <h1> is rendered there too, along with the six-box fallback wrapped in <Suspense>:

export const dynamic = "force-dynamic";

export default function InformasiOlahragaPage() {
  return (
    <Container className="py-8">
      <h1 className="mb-6 text-2xl font-bold">Informasi Olahraga</h1>
      <Suspense
        fallback={
          <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
            {Array.from({ length: 6 }).map((_, i) => (
              <div key={i} className="h-32 animate-pulse rounded-lg bg-muted" />
            ))}
          </div>
        }
      >
        <CaborList />
      </Suspense>
    </Container>
  );
}

The App Router default works exactly like that: layouts and pages are Server Components, and a Client Component is what you reach for when the page needs interaction or browser APIs [1]. In this project only one piece needs interaction, so <CaborList /> is the only thing shipped to the client.

One Island, Six States, One Timer

cabor-list.tsx opens with "use client" and immediately owns the whole page state: active tab, search text, organization list, total, loading flag, and the organization currently open. Data that changes over time like this is called state [2], and state can only live in a Client Component.

"use client";

const [activeTab, setActiveTab] = useState(0);
const [search, setSearch] = useState("");
const [orgs, setOrgs] = useState<Organization[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [selectedOrg, setSelectedOrg] = useState<Organization | null>(null);

useEffect(() => {
  const timer = setTimeout(fetchData, 350);
  return () => clearTimeout(timer);
}, [fetchData]);

Look at the last six lines. Searching does not fire a request right away: fetchData is scheduled 350 milliseconds out, and the clearTimeout in the cleanup function cancels the previous schedule every time the tab or the search text changes. React runs the cleanup with the old values before running the next setup [3], so only the last keystroke reaches the server. Without that cleanup, five letters typed quickly would send five requests, and their answers could arrive out of order.

An Effect whose dependencies keep changing does re-run after every commit [3]; here that is exactly what I want, because tab and search changes are legitimate data-fetch triggers. Everything else is presentation: a skeleton while loading, the "Tidak ada data ditemukan." empty state, an aria-label on the search input, and data-testid hooks on the tabs, the search field, and the result count for the end-to-end tests.

Data access itself goes through one wrapper, listOrganizations, which follows the conventions already used in endpoints.ts, and its types come from v1.d.ts generated from the API spec. Contract changes are therefore not retyped in this page: if a field changes in the spec, the type changes with it and the import here complains at build time.

Clicking a card does not open a new route. selectedOrg gets a value, CaborList stops returning the list and returns <CaborDetail /> with the board table instead, and the back button empties that state again. One island, two views, no navigation; a sensible choice for a list you only open and close on the same screen.

The useful question when splitting a page like this is not "server or client" but which half still has to work with JavaScript turned off: the title, the metadata, and the empty state, not the contents of the list.

Sources:

Related articles