Building Admin Product Routes
Building Admin Product Routes
Welcome to the first lesson of the new course, Product Operations and Admin Workflows. Up to this point, the app has focused on customer-facing shopping and order flows: browsing products, checking out, reviewing orders, enriching order details, and finally managing order lifecycle actions like payment and cancellation. Now the project expands into internal tooling, which means you are no longer only building for shoppers — you are also building for administrators who need a structured workspace for managing the catalog.
In this lesson, you will create the first dedicated admin product route and connect it to a focused client page. You will also extend the shared navbar so the internal workspace is reachable from the shell, then build the initial admin product screen using the same collection-loading and page-state patterns the storefront already trusts. Finally, you will add page-local selection state so the admin view starts to feel like a real management workspace instead of only a read-only list.
Previously
In the previous unit, you finished the customer-side order experience by making the order detail page actionable. The app could already load orders and enrich line items, but the last lesson added lifecycle actions like Pay order and Cancel order, along with semantic API helpers, refresh-driven hooks, pending interaction state, and toast feedback. That work taught an important frontend lesson: good UI actions do not just trigger requests, they preserve trust by showing only valid actions, reflecting progress honestly, and re-reading canonical server state afterward.
This new course builds on that same architectural discipline, but in a different part of the app. Instead of shopper order pages, you are now starting an internal admin workflow. The same habits still matter: route files stay thin, hooks own async state, shared UI primitives keep layout consistent, and components should read clearly in terms of user intent rather than low-level mechanics.
Starting the Admin Route with a Thin Page File
The new route begins in src/app/admin/products/page.tsx. Just like other route files in this project, this file should not become a logic container. Its job is simply to activate the URL and hand rendering responsibility to a dedicated client component.
Here is the full route file:
-
The route imports
ProductAdminPageClientand immediately renders it, which keeps the file extremely easy to scan. This is a strong App Router pattern because route files remain declarative entry points instead of turning into large UI implementation files. -
Returning the client component directly also keeps routing concerns separate from interaction concerns. The route defines where the page lives, while the client component defines how the page behaves once it is active.
-
This mirrors patterns already used elsewhere in the codebase, which is important for maintainability. When all route files follow the same “thin handoff” structure, the project becomes easier to navigate because developers know exactly where to look for actual page behavior.
Extending the Shared Navbar for Admin Navigation
A page is much more useful when the app shell acknowledges that it exists. In this project, the shared navbar in src/components/layout/Navbar.tsx already uses a small data-driven structure to render navigation links, so this lesson extends that existing pattern rather than adding a special-case admin link somewhere else.
Here is the navItems array after the new destination is added:
-
Adding
{ href: '/admin/products', label: 'Admin Products' }is intentionally simple, and that simplicity is a strength. Because the navbar is already data-driven, introducing a new destination is just a matter of extending the list rather than rewriting rendering logic. -
It is important that the existing customer-facing items stay in place. This course is expanding the product, not replacing the storefront with an admin-only interface, so the navigation should continue to reflect both parts of the app.
-
Notice that only Admin Products is introduced here. That keeps the shell aligned with the actual feature surface of the project at this moment, instead of exposing future internal routes that do not exist yet.
The rendering logic in the same file stays unchanged, which is exactly what we want:
-
Because the new admin destination uses the same
{ href, label }shape as the existing items, it automatically inherits the navbar’s active-link behavior. That is a great example of why small data-driven UI structures make a frontend easier to extend. -
The
activecalculation already supports nested paths throughpathname.startsWith(item.href)for non-root routes. That means/admin/productsparticipates in the same selection styling logic without any extra branching. -
This pattern keeps the navbar coherent as the project grows. Rather than teaching every new feature to add links in a custom way, the shell exposes a single consistent expansion point.
Reusing the Existing Product Collection Hook
The admin page in src/components/admin/ProductAdminPageClient.tsx begins by reusing useProducts() from src/lib/hooks/useProducts.ts. That is a very important design choice. Even though this is an internal tool instead of a storefront page, it still needs the same reliable product collection loading model the rest of the app already trusts.
Here is the hook definition:
-
useProducts()exposes the exact kind of state an admin list page needs:products,isLoading, anderrorMessage. That trio is enough to let the page distinguish between the important collection states without teaching the component how to fetch data itself. -
The hook is already written in the same disciplined style used elsewhere in the project. It keeps request orchestration inside the hook and gives the page a small, clean public API that reads naturally from a UI perspective.
-
Reusing this hook is also a lesson in architectural consistency. Admin pages should not invent a separate loading pattern just because they are internal; consistency makes both customer tools and internal tools easier to maintain.
The hook’s refresh() logic shows the same guarded loading behavior used across the project:
-
This function keeps the page’s request lifecycle predictable. It starts loading, fetches products, ignores stale responses, clears errors after success, and resets the list plus error state appropriately on failure.
-
Returning the collection from
refresh()is useful for future workflows, but the important detail in this lesson is the stable state model it maintains. The admin page can remain focused on rendering states because the hook guarantees disciplined transitions between them. -
The
requestIdRefguard is worth noticing even here. Internal tools deserve the same resilience as customer-facing pages, and stale-request protection is part of that reliability.
Setting Up the Admin Page Shell
Now look at the start of src/components/admin/ProductAdminPageClient.tsx. This file is where the new admin workspace actually takes shape. It begins by reading from useProducts() and defining page-local selection state.
Here are the imports and the opening of the component:
-
The
'use client'directive is required because this component uses React state and a client-side hook. SinceuseProducts()depends on hooks likeuseEffectanduseState, the admin page must be rendered as a client component. -
const { products, isLoading, errorMessage } = useProducts();immediately shows the intended page model. The component is reading collection data and collection state from a shared hook rather than owning request logic directly. -
selectedProductis typed asProduct | null, which is the right choice for this stage of the admin workspace. The selection only matters inside this page, so it belongs in local page state instead of being lifted elsewhere prematurely. -
Defining
selectedProductnow also prepares the file for future editing workflows. Even though this lesson stops at selection, the state introduces the idea that the admin page is becoming a workspace where one chosen product can drive nearby tools.
Building the Heading and Helper Panel First
Before any loading or error branches are shown, the page renders a section heading and a dashed helper panel. This is a very intentional choice. Even internal tools benefit from visual structure, and rendering that structure first helps the workspace feel deliberate rather than like a raw data dump.
Here is the opening layout:
-
PageContainergives the admin page the same outer spacing rhythm used by the rest of the app. Reusing shared layout primitives helps the admin tools feel like part of the same product rather than a disconnected mini-app. -
SectionHeadingis especially useful here because it introduces the purpose of the page immediately. The title and description make it clear that this route is about product operations and that it will evolve into a fuller workspace over time. -
The description is forward-looking without being misleading. It tells the learner that create, edit, and archive actions are part of the direction of this screen, but the current lesson keeps its focus on route structure, list rendering, and selection.
Right below that, the dashed helper panel reacts to the currently selected product:
-
This panel is a good example of lightweight, state-driven workspace guidance. When no product is selected, it invites the user to choose one; when a product is selected, it confirms that choice and explains how the workspace will evolve next.
-
Using the selected product’s name in the message makes the page feel more interactive and grounded. Even before editing tools exist, the UI already behaves like a product management workspace instead of only a static list.
-
Keeping this panel above the collection state branches is a smart structural decision. It gives the page a stable “control surface” feeling even before the list is rendered, which is valuable in internal tools where layout clarity matters just as much as raw functionality.
Handling Loading, Error, and Empty States Clearly
The next section of the page handles the admin collection states. These are not just technical fallbacks. They are part of the user experience, especially in internal tools where clarity matters more than decorative presentation.
Here is the loading and error logic:
-
LoadingStateappears when the initial collection request is in flight, using a direct and friendly message. That kind of explicit wording matters because it tells the user exactly what the tool is currently doing. -
The error branch only renders after loading has finished, which keeps the state model clean. The page is careful not to mix “still fetching” and “failed” into the same moment, which makes the UI easier to interpret.
-
The message from
errorMessageis passed through directly rather than being hidden or overly transformed. That is especially appropriate in internal tools, where honest failure reporting is usually more helpful than overly polished vagueness.
Here is the empty state:
-
This branch communicates a successful but empty result, which is an important distinction. The page is working correctly here; it simply has no products to manage yet.
-
EmptyStatehelps the admin tool feel intentional even when there is nothing in the list. Instead of leaving a blank gap that could be mistaken for a bug, the screen explains exactly what the current state means. -
The description also frames the empty list in terms of the product roadmap. It acknowledges that creation workflows are still coming, which makes the current empty state feel like a normal stage of development rather than a dead end.
Rendering Real Product Cards in the Populated State
Once the page finishes loading successfully and has products to show, it replaces the temporary placeholder with a real list of management cards. These cards are not storefront product cards. They are internal administration rows, so they highlight operational metadata instead of sales-oriented presentation.
Here is the populated-state branch:
-
Each
articleacts as a compact internal management card rather than a customer-facing merchandise tile. The layout emphasizes scanability and operational context, which is exactly what an admin list should prioritize. -
The product name is shown prominently with larger serif typography, while
StatusBadgesits alongside it so product status is easy to interpret at a glance. This pairing is important because status is one of the first signals an internal user usually checks. -
The metadata line uses
product.sku,formatMoney(product.price_cents, product.currency), andproduct.inventory_countto create an internal-dashboard feel. This is different from storefront UI on purpose: admin tools need identifiers, price context, and inventory visibility more than visual marketing cues. -
The use of
formatMoney(...)keeps currency display consistent with the rest of the app. Shared formatting utilities are valuable because they make both shopper and admin surfaces speak the same visual language for domain data. -
The
Selectbutton does not mutate anything yet; it only stores the clicked product inselectedProduct. That is an excellent intermediate step for teaching because it introduces workspace interaction without mixing in editing or API mutation complexity too early. -
The button’s click handler is short and intention-revealing:
onClick={() => setSelectedProduct(product)}. This is a good example of how page-local state can support meaningful interaction without needing to be abstracted away prematurely. -
The row layout remains responsive through
flex-colon smaller screens andmd:flex-rowwith aligned spacing on larger screens. That matters because internal tools still need to be readable and pleasant to use across viewport sizes, even when they are more utilitarian than storefront screens.
Why Local Selection State Belongs on the Page
It is worth pausing on the role of selectedProduct. This state is intentionally page-local because it does not describe shared global application state. It only matters inside the admin product workspace, and specifically inside this one page’s current interaction flow.
That makes useState<Product | null>(null) the right tool. The page can show a contextual helper message, let the user choose one product at a time, and prepare for future editing tools without introducing unnecessary abstraction. This is a helpful frontend principle: keep state as local as possible until there is a real reason to lift it.
Recap
In this lesson, you created the first internal product management route and connected it cleanly to the rest of the application.
You started with src/app/admin/products/page.tsx, where the route stayed deliberately thin by rendering ProductAdminPageClient directly. Then you extended src/components/layout/Navbar.tsx by adding only one new destination — /admin/products — to the existing navItems array, which automatically gave the new route the same active-link behavior as the rest of the shell.
After that, src/components/admin/ProductAdminPageClient.tsx reused useProducts() so the admin page could rely on the same collection-loading model already trusted elsewhere in the storefront. The page rendered a structured heading and helper panel first, handled loading, error, and empty states clearly, and then displayed real product cards in the populated state. Finally, it introduced page-local selectedProduct state so the workspace can react to a chosen product and prepare for the editing tools that come next.
The main takeaway is that admin workflows should begin with the same structural discipline as customer-facing features. Thin routes, shared hooks, honest page states, and small data-driven shell patterns make internal tooling easier to grow and easier to trust.
