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:

import { ProductAdminPageClient } from '@/components/admin/ProductAdminPageClient';

export default function AdminProductsPage() {
  return <ProductAdminPageClient />;
}
  • The route imports ProductAdminPageClient and 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:

const navItems = [
  { href: '/', label: 'Home' },
  { href: '/shop', label: 'Shop' },
  { href: '/orders', label: 'Orders' },
  { href: '/admin/products', label: 'Admin Products' },
];
  • 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:

<nav className="flex flex-wrap items-center gap-2">
  {navItems.map((item) => {
    const active = pathname === item.href || (item.href !== '/' && pathname.startsWith(item.href));
    return (
      <Link
        key={item.href}
        href={item.href}
        className={clsx(
          'rounded-full px-4 py-2 text-sm transition-colors',
          active ? 'bg-stone-900 text-white' : 'text-stone-600 hover:bg-white hover:text-stone-900'
        )}
      >
        {item.label}
      </Link>
    );
  })}
</nav>
  • 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 active calculation already supports nested paths through pathname.startsWith(item.href) for non-root routes. That means /admin/products participates 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:

'use client';

import { useCallback, useEffect, useRef, useState } from 'react';
import { getErrorMessage } from '@/lib/api/client';
import { listProducts } from '@/lib/api/products';
import { Product } from '@/types/domain';

export function useProducts(query?: string) {
  const [products, setProducts] = useState<Product[]>([]);
  const [isLoading, setIsLoading] = useState(true);
  const [errorMessage, setErrorMessage] = useState<string | null>(null);
  const requestIdRef = useRef(0);
  • useProducts() exposes the exact kind of state an admin list page needs: products, isLoading, and errorMessage. 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:

  const refresh = useCallback(async () => {
    const requestId = requestIdRef.current + 1;
    requestIdRef.current = requestId;
    setIsLoading(true);

    try {
      const response = await listProducts(query);
      if (requestId !== requestIdRef.current) return [];
      setProducts(response);
      setErrorMessage(null);
      return response;
    } catch (error) {
      if (requestId !== requestIdRef.current) return [];
      setProducts([]);
      setErrorMessage(getErrorMessage(error));
      return [];
    } finally {
      if (requestId === requestIdRef.current) {
        setIsLoading(false);
      }
    }
  }, [query]);
  • 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 requestIdRef guard 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:

'use client';

import { useState } from 'react';
import { PageContainer } from '@/components/layout/PageContainer';
import { EmptyState } from '@/components/ui/EmptyState';
import { ErrorState } from '@/components/ui/ErrorState';
import { LoadingState } from '@/components/ui/LoadingState';
import { SectionHeading } from '@/components/ui/SectionHeading';
import { StatusBadge } from '@/components/ui/StatusBadge';
import { useProducts } from '@/lib/hooks/useProducts';
import { formatMoney } from '@/lib/utils/format';
import { Product } from '@/types/domain';

export function ProductAdminPageClient() {
  const { products, isLoading, errorMessage } = useProducts();
  const [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
  • The 'use client' directive is required because this component uses React state and a client-side hook. Since useProducts() depends on hooks like useEffect and useState, 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.

  • selectedProduct is typed as Product | 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 selectedProduct now 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:

  return (
    <PageContainer className="space-y-8 py-12 md:py-16">
      <SectionHeading
        eyebrow="Admin"
        title="Product management"
        description="Review the current catalog and prepare the internal workspace for create, edit, and archive actions."
      />
  • PageContainer gives 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.

  • SectionHeading is 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:

      {selectedProduct ? (
        <div className="rounded-[2rem] border border-dashed border-stone-300 bg-white/70 p-6 text-sm leading-6 text-stone-600">
          Editing tools will be added in the next unit for <span className="font-semibold text-stone-900">{selectedProduct.name}</span>.
        </div>
      ) : (
        <div className="rounded-[2rem] border border-dashed border-stone-300 bg-white/70 p-6 text-sm leading-6 text-stone-600">
          Select a product below to prepare the edit workspace.
        </div>
      )}
  • 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:

      {isLoading ? <LoadingState message="Loading products..." /> : null}
      {!isLoading && errorMessage ? <ErrorState message={errorMessage} /> : null}
  • LoadingState appears 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 errorMessage is 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:

      {!isLoading && !errorMessage && products.length === 0 ? (
        <EmptyState title="No products created" description="Products will appear here once the admin create flow exists." />
      ) : null}
  • 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.

  • EmptyState helps 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:

      {!isLoading && !errorMessage && products.length > 0 ? (
        <div className="space-y-4">
          {products.map((product) => (
            <article
              key={product.id}
              className="flex flex-col gap-4 rounded-[2rem] border border-stone-200 bg-white p-6 shadow-sm md:flex-row md:items-center md:justify-between"
            >
              <div className="space-y-2">
                <div className="flex items-center gap-3">
                  <p className="font-serif text-2xl text-stone-950">{product.name}</p>
                  <StatusBadge status={product.status} />
                </div>
                <p className="text-sm text-stone-500">
                  {product.sku} · {formatMoney(product.price_cents, product.currency)} · {product.inventory_count} in stock
                </p>
              </div>
              <button
                type="button"
                onClick={() => setSelectedProduct(product)}
                className="rounded-full border border-stone-200 bg-white px-4 py-2 text-sm font-medium text-stone-900 transition-colors hover:bg-stone-50"
              >
                Select
              </button>
            </article>
          ))}
        </div>
      ) : null}
    </PageContainer>
  );
}
  • Each article acts 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 StatusBadge sits 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), and product.inventory_count to 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 Select button does not mutate anything yet; it only stores the clicked product in selectedProduct. 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-col on smaller screens and md:flex-row with 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.

Sign up

Join the 1M+ learners on CodeSignal

Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal