Skip to content

Three 7-Line Routes, One 471-Line Component

Adityo Guni Waluyo

The Kelembagaan admin module: three 7-14 line server routes, one 471-line client editor, a 350 ms debounced list, and an id guard that throws 404.

TL;DR

Commit c50f35b adds the Kelembagaan module. It includes organization list and edit forms with validation. Validation occurs on the server side for security.

The URL /admincms/organisasi/abc was opened in a browser. What came back was not an empty form or an inline validation message, but Next.js's own 404 page, and no data request ever left the browser. That change is commit c50f35b, +908 lines across 7 files, adding the Kelembagaan module to a city portal's admin CMS: an organization list, a create form, and an edit form with a members editor.

Seven Lines Doing Two Jobs

The module has three route files, 7 to 14 lines each. Two of them only write the page metadata and render one client component. The third, the edit route, does one more thing before that component is even loaded:

export default function EditOrganisasiPage({ params }: { params: { id: string } }) {
  const id = Number(params.id);
  if (!Number.isInteger(id) || id <= 0) notFound();
  return <OrganisasiEditor id={id} />;
}

params.id arrives as a string, and turning "abc" through Number yields NaN, not an error. notFound() itself throws an error that renders a Next.js 404 page and terminates rendering of the route segment where it was called [2], so the edit component never runs with a broken id.

The simpler guess was tempting: validate the id inside the edit component, in the part that already reads state from the client. That option puts the only guard on a layer that becomes active after JavaScript loads, on a route whose state is not reset on navigation either. The Next.js authentication guide states the structural reason: layouts do not re-render on navigation, so checks belong close to the data source or in the conditionally rendered component [4].

One Client Leaf Behind a Server Shell

All of the module's activity lives in two client components: a 471-line editor and a 361-line list view. Both open with "use client", the directive that declares a boundary between the Server and Client module graphs [1]. The Kelembagaan entry itself joins the sidebar with the permission organisasi:read. Routes stay server components, responsible only for writing the title, validating the param, and handing over one client child.

The editor reads the permission from the session and disables write buttons for users without organisasi:write. A gate like that is a user-interface affordance, not a security boundary: it hides buttons, while the real guarantee still sits with the API that accepts the request. Its value for users is real, as long as it is not treated as a layer of defense [4]. The module installs both: the client gate for convenience, and the server route for the part that must not depend on JavaScript.

A List That Waits Instead of Sleeping

The list view loads 20 rows per page and searches with a 350-millisecond debounce built from setTimeout cleaned up by clearTimeout. React cleans up the previous render's Effect before the next render's Effect runs, so during fast typing there is still only one timeout scheduled at a time [3]. Without that cleanup, five keystrokes become five requests.

Inside the same effect sits a stale-response guard: a response from an older filter that arrives later must be ignored instead of written to state. The React documentation names two options for this case, abort the fetch or ignore its result [3], and this list takes the second one through an ignore flag. After an organization is deleted, the list does not trigger a new fetch on its own; the reload runs through a separate refresh key.

useEffect(() => {
  let ignore = false;
  const t = setTimeout(async () => {
    const res = await adminApi.listOrganizations({ q: debouncedQ, page });
    if (!ignore) { setItems(res.items); setTotal(res.total); }
  }, 350);
  return () => { ignore = true; clearTimeout(t); };
}, [debouncedQ, page]);

The Category That Is Never Sent

The organization detail response carries no category field, yet the edit form has to show the right category. The editor solves it by reading tipe_cabor: BADAN_FUNGSIONAL becomes Badan Fungsional, PENCABUTAN becomes an expired-term branch, and everything else falls back to the sports-branch default. Missing data is reconstructed from the data that exists, never guessed from user state.

At the API layer, all five organization methods, list, get, create, update, delete, are written against types generated from the OpenAPI schema. openapi-typescript turns OpenAPI 3.0 and 3.1 schemas into TypeScript [5], so the members list riding inside the edit request reads the same type as the OrganizationUpsertRequest schema. A contract change on the server side surfaces immediately as an error in the editor.

The decision that stands out after this commit: the routes were kept thin so that the one part requiring a strict guard, id validation, lives where it is easiest to trust. The remaining 879 lines sit in the client components and the API wrapper, two parts that are free to reload as often as needed.

Sources:

Related articles