Back to Blog
ReactTypeScriptStorybook

How I structured a React frontend for a coffee shop app

·7 min read

This is a walkthrough of the frontend architecture I built for Beanworks, a fictional specialty coffee shop. The goal was to treat it as a real production project: a custom design system, typed API layer, Storybook integration, and JWT-based auth with role guards.


Tech stack

ToolPurpose
React 19 + TypeScriptUI and type safety
Vite + Tailwind CSS v4Build tooling and styling
React Router v7Client-side routing
TanStack Query (react-query) v5Server state and caching
Zustand v5Client state (auth)
react-hook-form + ZodForms and validation
Framer MotionAnimations
Storybook 8Component development and docs
class-variance-authority (CVA)Component variant API

Folder structure

CODE
src/
├── api/           # Axios client + typed API functions
├── components/    # Feature components (auth, products, layout, etc.)
├── design-system/ # Primitive UI components + design tokens
├── hooks/         # TanStack Query (react-query) wrappers
├── pages/         # Route-level page components
├── router/        # Route config + PrivateRoute guard
├── store/         # Zustand stores
├── types/         # Shared API types
└── utils/         # cn, formatCurrency, formatDate

The split between components/ and design-system/ was deliberate. design-system/ holds primitives like Button, Card, Input, and Badge that have no domain knowledge. components/ holds feature-specific things like CoffeeBeanCard and LoginForm that compose those primitives.


Design system and tokens

Instead of scattering raw hex values through component files, I defined a token file:

TypeScript
// src/design-system/tokens/colors.ts
export const colors = {
  brand: {
    warmWhite: '#FAF9F6',
    beige: '#EDE7DD',
    charcoal: '#2E2E2E',
    brown: '#A67C52',
    sage: '#A8B2A1',
  },
  primary: {
    DEFAULT: '#A67C52',
    hover: '#8F6844',
  },
  text: {
    DEFAULT: '#2E2E2E',
    muted: '#7A7266',
    subtle: '#B0A89C',
  },
  // ...
} as const

Components then reference CSS variables instead of the token values directly, so a future theme swap doesn't require touching every file.

For component variants, I used CVA. Here's the Button as an example:

TSX
const buttonVariants = cva(
  ['inline-flex items-center justify-center gap-2 font-medium rounded-xl', 'transition-all duration-[250ms]'],
  {
    variants: {
      variant: {
        primary: ['bg-[var(--color-primary)] text-white', 'hover:bg-[var(--color-primary-hover)]'],
        secondary: ['bg-[var(--color-surface-elevated)]', 'border border-[var(--color-border)]'],
        ghost: ['bg-transparent', 'hover:bg-[var(--color-surface-elevated)]'],
        danger: ['bg-red-50 text-red-700 border border-red-200'],
      },
      size: {
        sm: 'h-8 px-3 text-sm rounded-lg',
        md: 'h-10 px-5 text-sm',
        lg: 'h-12 px-7 text-base rounded-2xl',
      },
    },
    defaultVariants: { variant: 'primary', size: 'md' },
  },
)

CVA keeps variants co-located and makes the props contract explicit via VariantProps<typeof buttonVariants>.


API layer

All HTTP calls go through a single Axios instance in src/api/client.ts. The two interceptors do the heavy lifting:

  • Request interceptor: reads the JWT from localStorage (via Zustand's persist key) and attaches it as a Bearer token
  • Response interceptor: catches 401s, clears the stored auth, and redirects to /login with the current path as a ?redirect param
TypeScript
apiClient.interceptors.response.use(
  (response) => response,
  (error) => {
    if (axios.isAxiosError(error) && error.response?.status === 401) {
      localStorage.removeItem('coffee_shop_auth')
      window.location.href = `/login?redirect=${encodeURIComponent(window.location.pathname)}`
    }
    return Promise.reject(error)
  },
)

Typed functions in src/api/products.ts and src/api/auth.ts wrap the client. Each function returns a typed DTO, so the rest of the app never deals with raw any responses.


Data fetching with TanStack Query (react-query)

Rather than calling the API functions directly in components, each data type gets its own hook in src/hooks/:

TypeScript
export function useCoffeeBeans() {
  return useQuery({
    queryKey: ['coffeeBeans'],
    queryFn: getCoffeeBeans,
    staleTime: 5 * 60 * 1000,
  })
}

Pages just call useCoffeeBeans() and destructure { data, isLoading, isError }. The 5-minute stale time means navigating between pages doesn't re-fetch unless the data is actually old. Query keys are defined as typed as const arrays to prevent key typos from causing cache misses.


Auth state with Zustand

Auth lives in a single Zustand store with the persist middleware:

TypeScript
export const useAuthStore = create<AuthState>()(
  persist(
    (set) => ({
      token: null,
      user: null,
      isAuthenticated: false,

      login: (token: string) => {
        const claims = jwtDecode<JwtClaims>(token)
        const roles = claims['http://schemas.microsoft.com/ws/2008/06/identity/claims/role']
        set({ token, user: { email, firstName, roles }, isAuthenticated: true })
      },

      logout: () => set({ token: null, user: null, isAuthenticated: false }),
    }),
    { name: 'coffee_shop_auth' },
  ),
)

On login, the JWT is decoded client-side with jwt-decode to extract the user's email, given name, and roles. The backend here uses .NET's default claim URI format for roles, so the decode maps that long URI key into a plain roles: string[] array the rest of the app can use.

The store persists to localStorage under the key coffee_shop_auth. The Axios interceptor reads from that same key, which avoids any import-cycle issues between the store and the client.


Route guards

PrivateRoute wraps protected pages and handles two cases: unauthenticated users and non-admin users trying to hit admin routes.

TSX
export function PrivateRoute({ children, requireAdmin = false }: PrivateRouteProps) {
  const { isAuthenticated, user } = useAuthStore()
  const location = useLocation()

  if (!isAuthenticated) {
    return <Navigate to={`/login?redirect=${encodeURIComponent(location.pathname)}`} replace />
  }

  if (requireAdmin && !user?.roles.includes('Admin')) {
    return <Navigate to="/" replace />
  }

  return <>{children}</>
}

Usage in the route config is straightforward:

TSX
<Route
  path="/admin/products"
  element={
    <PrivateRoute requireAdmin>
      <AdminProductsPage />
    </PrivateRoute>
  }
/>

Storybook as the frontend's Swagger

If you've worked on a backend API, you've probably used Swagger UI to browse endpoints, inspect request/response shapes, and fire off test requests without writing any code. Storybook does the same thing for UI components.

Every component gets a .stories.tsx file that lives right alongside it. Each story maps to a named variant — Default, Bronze, Silver, Gold for a membership card; Default, Dark Roast, Low Stock, Out Of Stock for a coffee bean card. Anyone on the project can open Storybook, browse the catalogue, and see every visual state without needing to reproduce it in the running app.

MembershipCard story showing props table and live preview

The props table at the bottom is generated automatically from TypeScript types. It shows every prop name, its type, whether it's required, and a live control to change it in real time. For MembershipCard, the membership prop is typed as MembershipDto, so the table lists all the fields (id, points, tier, joinedAt) and lets you edit them directly.

CoffeeBeanCard story with multiple variants in the sidebar

This project uses @storybook/addon-a11y for accessibility auditing on each story and @storybook/addon-themes for theme switching. The a11y addon in particular catches contrast and ARIA issues at the component level, before they reach the page.

A few concrete benefits this gave me:

  • Backend-free development. Stories use hardcoded fixture data, so I could build and refine CoffeeBeanCard and MembershipCard without a running API.
  • Variant coverage. Writing a Low Stock story forced me to actually implement the low-stock visual state, which I might have skipped if I only tested through the app.
  • Living documentation. The stories serve as examples for anyone reading the code later. Instead of guessing what props a component accepts, you open Storybook and see it.

Path aliases

Vite's resolve.alias maps the most-used directories to short imports:

TypeScript
alias: {
  '@': resolve(__dirname, './src'),
  '@ds': resolve(__dirname, './src/design-system'),
  '@components': resolve(__dirname, './src/components'),
  '@pages': resolve(__dirname, './src/pages'),
  '@hooks': resolve(__dirname, './src/hooks'),
  '@store': resolve(__dirname, './src/store'),
  '@api': resolve(__dirname, './src/api'),
}

So instead of ../../../design-system/components/Button, any file can write @ds/components/Button. TypeScript picks these up via the matching paths config in tsconfig.json.