DEV Community

Israel Michael
Israel Michael

Posted on

Inside Tabs: A Practical Next.js Frontend for Multi-Business Inventory Operations

Tabs single business dashboard

Tabs is a multi-tenant operations frontend built with Next.js App Router. At a product level, it is trying to keep the daily workflow of a small or midsize business in one place: products, stores, orders, transfers, customers, people, and roles all live under the same business-scoped UI. You can see that shape directly in the route tree under app/(authenticated)/[slug], where the slug identifies the active business, and in components/home/business-list.tsx, which acts as the switchboard for choosing which business workspace to open.

That matters because the problem Tabs is solving is not just inventory tracking. It is reducing the operational overhead of hopping between separate systems for stock, staff access, customer records, and order fulfilment. The codebase reflects that ambition. It is broad, form-heavy, and biased toward shipping CRUD workflows quickly.

The application shell is layered in a way that makes that scope manageable. app/layout.tsx owns global metadata, fonts, and the top-level provider composition. components/providers/providers.tsx then stacks ContextProvider, ThemeProvider, TooltipProvider, QueryProvider, and KeyboardCommandsProvider in a single place. The authenticated shell in app/(authenticated)/layout.tsx adds TopNav, sidebar state, and the scroll container. Finally, app/(authenticated)/[slug]/layout.tsx introduces the actual business workspace with DesktopSidebar, DashboardContent, and ScrollToTop.

One of the better decisions here is that business context is inferred rather than threaded manually. components/providers/business-provider.tsx reads usePathname(), strips out known non-business routes like settings and create-business, and exposes business_slug through context. That means components deeper in the tree can call useBusiness() and build business-scoped queries without prop-drilling the slug through every intermediate layout and card.

The data access layer is similarly pragmatic. Each domain gets a thin service module in services/*.service.ts, while services/_http.service.ts holds the Axios instance, the tbs-user-sid request interceptor, and the 401 response handling that calls logout() and shows a toast. It is not a rich domain layer; it is intentionally light. Most of the orchestration lives in the client components. That tradeoff shows up everywhere: thin service modules, fat screen components.

React Query is wrapped in hooks/use-query-resource.ts so the app consumes a smaller API than raw TanStack Query. The hook is doing a few jobs at once: standardizing toasts, exposing onSuccess and onError, and invalidating related queries after mutations.

export const useModifyResource = <T>(options: MutationOptionsProps<T>) => {
  const { key, fn, onSuccess, onError, invalidateKeys, ...mutationOptions } = options;
  const queryClient = useQueryClient();

  return useMutation({
    ...mutationOptions,
    mutationFn: fn,
    onSuccess: (data) => {
      if (onSuccess) onSuccess(data);
      if (invalidateKeys?.length) {
        invalidateKeys.forEach((queryKey) => {
          queryClient.invalidateQueries({ queryKey });
        });
      }
    },
  });
};
Enter fullscreen mode Exit fullscreen mode

That wrapper is useful because most screens follow the same pattern: fetch with useGetResource, mutate with useModifyResource, and let invalidation refresh the table or detail view. The tradeoff is that the wrapper becomes part of the application architecture, so mistakes inside it spread widely. One example is components/providers/query-provider.tsx, which instantiates new QueryClient() directly inside the component body. In practice that risks rebuilding the cache more often than intended.

The roles feature is a good example of translating UI state into API state without overengineering it. In app/(authenticated)/[slug]/roles/_components/roles-content.tsx, the UI groups permissions by category such as customers or stores, but the backend expects normalized strings like customer:view or location:remove. The component handles both directions locally:

const flattenPermissions = (permissions: Record<string, string[]>) => {
  const list: string[] = [];
  Object.entries(permissions).forEach(([category, actions]) => {
    const resource = categoryToResource[category] || category;
    actions.forEach((action) => {
      list.push(`${resource}:${actionToPermission[action] || action}`);
    });
  });
  return [...new Set(list)];
};
Enter fullscreen mode Exit fullscreen mode

There is a matching toGroupedPermissions() for the reverse mapping when loading role details. It is a small but honest piece of application logic: the backend permission model is cleaner than the UI model, so the frontend adapts. RoleDialog and RoleSheet stay mostly focused on presentation because RolesContent owns the transformations and the fetch/mutate flow.

The most complex implementation is probably the order creation flow, especially app/(authenticated)/[slug]/orders/create-order/_components/step-4-product-details.tsx. That final step handles product selection, quantity changes, VAT toggling, derived totals, optional customer creation, and order submission in one component. It is a lot of responsibility, but it also shows the project's bias: keep the workflow close to the UI that owns it. The handleSubmit path is especially telling. It first tries to reuse an existing customer, then conditionally creates one, then builds the order payload and redirects to the new order. That is not the most layered design, but it is direct and easy to trace while debugging.

Forms across the app follow a consistent stack: react-hook-form, zodResolver, and component-local orchestration. app/(authenticated)/[slug]/products/_components/product-form.tsx is representative. It combines useForm, useFieldArray, useGetResource, useModifyResource, and a preview sidebar to support both create and edit flows. The result is a fairly capable form without introducing a separate state machine library. The downside is that these large client components are becoming maintenance hotspots, because validation, loading, transformation, side effects, and UI are often sitting in the same file.

The request flow is straightforward. components/auth/login/login-form.tsx calls signIn(), extracts tbs-user-sid, and stores it with js-cookie. middleware.ts uses the presence of that cookie to gate authenticated routes and redirect to /login when needed. After that, services/_http.service.ts injects the same cookie into outgoing requests. Screen components call useGetResource and useModifyResource, then move the results into local state or occasionally global context. components/dashboard/navbar/user-nav.tsx, for example, fetches the profile and copies it into ContextProvider so hooks like usePermissions() can read the active user object.

State is split in a sensible, lightweight way. Server state lives mostly in React Query. Form state lives in react-hook-form and useFieldArray. View state such as dialog visibility or selected rows sits in useState. App-global state is minimal: components/providers/context.tsx stores the current user and geolocated country, and components/providers/sidebar-provider.tsx stores collapsed sidebar state in localStorage.

There are also a few solid cross-cutting concerns. Performance-wise, the app enables PWA support in next.config.mjs, serves an offline fallback from app/~offline/page.tsx, and uses LazyMotion in components/shared/animated-components.tsx to avoid paying for more animation runtime than needed. components/shared/data-table/index.tsx centralizes table behavior and can operate in either client or server pagination mode, which is a good fit for admin-style screens.

Security and accessibility are mixed in the way many real products are. On the positive side, middleware.ts centralizes navigation protection, and the Axios interceptor handles expired sessions consistently. On the less comfortable side, the base API URL in services/_http.service.ts is hard-coded to an ngrok endpoint, session handling is done in client JavaScript rather than server-managed cookies, and hooks/use-permissions.ts currently allows all actions when no permissions are present. That last choice may be convenient during integration, but it is a dangerous default. Accessibility is stronger: the app gets a good baseline from Radix and shadcn primitives in components/ui, and that shows up in labeled forms, dialogs, sheets, dropdown menus, and keyboard shortcuts.

If I were continuing this codebase, I would keep the route-scoped multi-tenancy, the thin service modules, and the reusable UI primitives. They are helping the team move. I would change the infrastructure around them: stabilize the QueryClient, move auth toward server-managed cookies, replace hard-coded environment values, tighten the permissions fallback, and gradually extract orchestration out of the largest client components. Tabs already has the right product surface area for an operations app. The next step is making the implementation as durable as the workflows it is trying to support.

Top comments (0)