DEV Community

MATKARIM MATKARIMOV
MATKARIM MATKARIMOV

Posted on

Feature-Based React Architecture That Actually Scales

A deep dive into building a scalable React 19 enterprise app using feature-based architecture, TanStack Query, Zod with i18n, and a strict service layer pattern.

Introduction

I've been building FlowBoard — a full-featured React SPA for team collaboration, project tracking, and workflow management. After months of iteration, the codebase has grown to 350+ source files across 8 feature modules, supporting 4 languages, with a clean architecture that hasn't slowed me down.

In this article, I'll break down the architecture decisions, folder structure, and patterns that keep this codebase maintainable at scale. Whether you're starting a new project or refactoring an existing one, these patterns can help.

Tech Stack at a Glance:

Layer Technology
Framework React 19 + TypeScript 5.9
Build Vite 7
UI Radix UI + shadcn/ui + Tailwind CSS v4
Server State TanStack Query v5
Tables TanStack Table v8
Forms React Hook Form + Zod
Routing React Router 7
HTTP Axios (interceptors + token refresh)
i18n react-i18next (4 languages)
Code Quality Biome (linter + formatter)

The Problem with "File-Type" Organization

Most React tutorials teach you to organize by file type:

src/
├── components/
│   ├── UserList.tsx
│   ├── OrderCard.tsx
│   └── ProductForm.tsx
├── hooks/
│   ├── useUsers.ts
│   ├── useOrders.ts
│   └── useProducts.ts
├── services/
│   ├── userService.ts
│   ├── orderService.ts
│   └── productService.ts
└── types/
    ├── user.ts
    ├── order.ts
    └── product.ts
Enter fullscreen mode Exit fullscreen mode

This works fine until you have 20+ features. Then you're constantly jumping between 6 directories to work on a single feature. Files that change together should live together.


Feature-Based Architecture — The Core Idea

Every feature in the system is a self-contained module:

src/features/[feature-name]/
├── components/           # UI components (PascalCase.tsx)
│   ├── FeatureTable.tsx
│   ├── actions/          # CRUD modal forms
│   │   ├── CreateFeature.tsx
│   │   └── UpdateFeature.tsx
│   └── badges/           # Status/Priority badges
├── hooks/                # TanStack Query hooks (kebab-case.ts)
│   └── use-feature.ts
├── services/             # Pure API calls (kebab-case.service.ts)
│   └── feature.service.ts
├── schema/               # Zod validation (kebab-case.schema.ts)
│   └── feature.schema.ts
├── types.ts              # TypeScript interfaces
├── utils/                # Feature-specific helpers
│   ├── config.ts
│   └── table-config.ts
└── index.ts              # Public exports (optional)
Enter fullscreen mode Exit fullscreen mode

The rule is simple: if a file is only used by one feature, it belongs in that feature's folder.


Full Project Structure

Here's the folder tree that powers the entire application:

flowboard/
│
├── public/                         # Static assets (favicon, etc.)
├── src/
│   ├── assets/                     # Images, SVGs, logos
│   │
│   ├── components/                 # ~100 shared components
│   │   ├── ui/                     # 30+ Radix-based primitives
│   │   │   ├── button.tsx
│   │   │   ├── input.tsx
│   │   │   ├── dialog.tsx
│   │   │   ├── drawer.tsx
│   │   │   ├── badge.tsx
│   │   │   ├── card.tsx
│   │   │   ├── select.tsx
│   │   │   ├── tooltip.tsx
│   │   │   ├── avatar.tsx
│   │   │   └── ...
│   │   │
│   │   ├── common/                 # App-level shared components
│   │   │   ├── modal.tsx           # Responsive Dialog/Drawer
│   │   │   ├── sidebar.tsx
│   │   │   ├── search-input.tsx
│   │   │   ├── language-switcher.tsx
│   │   │   ├── theme-toggle.tsx
│   │   │   ├── alert-modal.tsx
│   │   │   └── lazy-component.tsx
│   │   │
│   │   ├── data-table/             # Reusable data table system
│   │   │   ├── data-table.tsx      # Main TanStack Table wrapper
│   │   │   ├── toolbar.tsx
│   │   │   ├── column-header.tsx
│   │   │   ├── pagination.tsx
│   │   │   ├── data-export.tsx     # CSV/Excel export
│   │   │   ├── table-settings.tsx
│   │   │   ├── hooks/
│   │   │   └── utils/
│   │   │
│   │   ├── form-inputs/            # ~14 specialized form inputs
│   │   │   ├── file-uploader.tsx
│   │   │   ├── date-picker.tsx
│   │   │   ├── date-range-picker.tsx
│   │   │   ├── multi-select.tsx
│   │   │   ├── searchable-select.tsx
│   │   │   ├── avatar-uploader.tsx
│   │   │   └── ...
│   │   │
│   │   ├── auth/                   # Route guards
│   │   └── navigation/             # Nav menu components
│   │
│   ├── features/                   # 8 self-contained feature modules
│   │   ├── auth/
│   │   │   ├── components/
│   │   │   ├── hooks/
│   │   │   ├── services/
│   │   │   ├── schema/
│   │   │   └── types.ts
│   │   │
│   │   ├── tickets/                # Largest feature (~24 components)
│   │   │   ├── components/
│   │   │   │   ├── TicketsTable.tsx
│   │   │   │   ├── KanbanBoard.tsx
│   │   │   │   ├── TicketDetail.tsx
│   │   │   │   ├── actions/
│   │   │   │   │   ├── CreateTicket.tsx
│   │   │   │   │   ├── UpdateTicket.tsx
│   │   │   │   │   └── DeleteTicket.tsx
│   │   │   │   └── badges/
│   │   │   │       ├── StatusBadge.tsx
│   │   │   │       └── PriorityBadge.tsx
│   │   │   ├── hooks/
│   │   │   │   ├── use-tickets.ts
│   │   │   │   ├── use-ticket-queries.ts
│   │   │   │   └── use-ticket-mutations.ts
│   │   │   ├── services/
│   │   │   │   └── tickets.service.ts
│   │   │   ├── schema/
│   │   │   │   └── tickets.schema.ts
│   │   │   ├── utils/
│   │   │   │   ├── config.ts
│   │   │   │   └── table-config.ts
│   │   │   └── types.ts
│   │   │
│   │   ├── workspaces/
│   │   ├── groups/
│   │   ├── members/
│   │   ├── divisions/
│   │   ├── account/
│   │   └── overview/
│   │
│   ├── pages/                      # ~18 route entry points
│   │   ├── auth/
│   │   │   └── Login.tsx
│   │   ├── overview/
│   │   │   ├── Overview.tsx
│   │   │   └── Analytics.tsx
│   │   ├── manage/
│   │   │   ├── Divisions.tsx
│   │   │   ├── Workspaces.tsx
│   │   │   ├── Groups.tsx
│   │   │   └── Members.tsx
│   │   ├── tickets/
│   │   │   ├── CreateTicket.tsx
│   │   │   ├── TicketDetail.tsx
│   │   │   ├── KanbanBoard.tsx
│   │   │   └── TicketsTable.tsx
│   │   └── errors/
│   │       ├── 403.tsx
│   │       ├── 404.tsx
│   │       └── 500.tsx
│   │
│   ├── hooks/                      # ~15 shared custom hooks
│   │   ├── use-auth-context.ts
│   │   ├── use-media-query.ts
│   │   ├── use-mobile.ts
│   │   ├── use-debounce.ts
│   │   ├── use-disclosure.ts
│   │   ├── use-theme.ts
│   │   ├── use-upload.ts
│   │   └── ...
│   │
│   ├── providers/                  # Context providers
│   │   ├── auth-provider.tsx
│   │   ├── theme-provider.tsx
│   │   └── page-title-provider.tsx
│   │
│   ├── context/                    # Context definitions
│   │   ├── auth-context.ts
│   │   ├── sidebar-context.ts
│   │   └── page-title-context.ts
│   │
│   ├── router/                     # Route definitions
│   │   ├── index.tsx
│   │   ├── app-routes.tsx
│   │   └── auth-routes.tsx
│   │
│   ├── layouts/                    # Layout wrappers
│   │   ├── AppLayout.tsx
│   │   └── AuthLayout.tsx
│   │
│   ├── locales/                    # i18n (4 languages)
│   │   ├── en/
│   │   │   ├── common.json
│   │   │   ├── navigation.json
│   │   │   ├── features/
│   │   │   │   ├── auth.json
│   │   │   │   ├── tickets.json
│   │   │   │   ├── workspaces.json
│   │   │   │   ├── members.json
│   │   │   │   └── ...
│   │   │   └── index.ts
│   │   ├── ru/                     # Same structure
│   │   ├── es/                     # Same structure
│   │   └── fr/                     # Same structure
│   │
│   ├── services/                   # Global services
│   │   ├── refresh-token.ts
│   │   └── upload.service.ts
│   │
│   ├── plugins/                    # Axios client setup
│   │   └── axios.ts
│   │
│   ├── types/                      # Shared type definitions
│   │   ├── common.ts
│   │   ├── enums.ts
│   │   └── navigation.ts
│   │
│   ├── utils/                      # Utility files
│   │   ├── token.ts
│   │   ├── format.ts
│   │   ├── permissions.ts
│   │   ├── enum-helpers.ts
│   │   └── zod-helpers.ts
│   │
│   ├── lib/
│   │   └── utils.ts                # cn() class merging
│   │
│   ├── styles/                     # Global CSS
│   ├── config/                     # App configuration
│   ├── main.tsx                    # Entry point
│   ├── App.tsx                     # Root component
│   └── i18n.ts                     # i18next config
│
├── biome.json                      # Linter + Formatter
├── vite.config.ts                  # Build config
├── tsconfig.json                   # TypeScript config
├── components.json                 # shadcn/ui registry
├── package.json
├── Dockerfile
├── docker-compose.yml
└── index.html
Enter fullscreen mode Exit fullscreen mode

By the numbers:

  • ~350 source files
  • ~200 .tsx components + ~110 .ts files
  • ~44 translation JSON files
  • ~100 shared components
  • 8 feature modules
  • ~15 custom hooks

Pattern #1: The Service Layer — API Calls Only

Services are the thinnest possible layer between your app and the backend. They contain zero business logic — just API calls.

// src/features/tickets/services/tickets.service.ts

import { axiosClient } from '@/plugins/axios';
import type { TicketCreate, TicketUpdate, TicketFilter } from '../types';

const ENDPOINTS = {
  CREATE: '/tickets',
  SEARCH: '/tickets/search',
  BY_ID: (id: string) => `/tickets/${id}`,
  STATUS: '/tickets/status',
} as const;

export const ticketService = {
  create: async (data: TicketCreate) => {
    const { data: response } = await axiosClient.post(
      ENDPOINTS.CREATE,
      data
    );
    return response;
  },

  getList: async (filter: TicketFilter) => {
    const { data: response } = await axiosClient.post(
      ENDPOINTS.SEARCH,
      filter
    );
    return response;
  },

  getById: async (id: string) => {
    const { data: response } = await axiosClient.get(
      ENDPOINTS.BY_ID(id)
    );
    return response;
  },

  update: async (id: string, data: TicketUpdate) => {
    const { data: response } = await axiosClient.put(
      ENDPOINTS.BY_ID(id),
      data
    );
    return response;
  },

  delete: async (id: string) => {
    const { data: response } = await axiosClient.delete(
      ENDPOINTS.BY_ID(id)
    );
    return response;
  },
};
Enter fullscreen mode Exit fullscreen mode

Why this matters:

  • Services are trivially testable (mock axios, assert calls)
  • Endpoint URLs are centralized and typed
  • The { data: response } destructure strips the axios wrapper — consumers get clean data
  • No try/catch here — error handling belongs in components or query hooks

Pattern #2: TanStack Query Hooks with Key Factories

Query key management is critical. Without a system, you'll invalidate the wrong queries and have stale data bugs everywhere.

// src/features/tickets/hooks/use-tickets.ts

import { useQuery, skipToken, keepPreviousData } from '@tanstack/react-query';
import { useMemo } from 'react';
import { ticketService } from '../services/tickets.service';

// Key factory — single source of truth
export const ticketKeys = {
  all: ['tickets'] as const,
  lists: () => [...ticketKeys.all, 'list'] as const,
  list: (filter: TicketFilter) => [...ticketKeys.lists(), filter] as const,
  details: () => [...ticketKeys.all, 'detail'] as const,
  detail: (id: string) => [...ticketKeys.details(), id] as const,
};

export function useTicketList(filter?: TicketFilter) {
  const { data, isLoading, isFetching, isRefetching, error, refetch } =
    useQuery({
      queryKey: filter ? ticketKeys.list(filter) : ticketKeys.lists(),
      queryFn: filter ? () => ticketService.getList(filter) : skipToken,
      placeholderData: keepPreviousData,
    });

  return useMemo(
    () => ({
      tickets: data?.data ?? [],
      total: data?.total ?? 0,
      isLoading,    // true only on initial load
      isFetching,   // true on any fetch (including background)
      isRefetching,  // true only on refetch
      error,
      refetch,
      isEmpty: !isLoading && !data?.data?.length,
    }),
    [data, isLoading, isFetching, error, refetch, isRefetching]
  );
}
Enter fullscreen mode Exit fullscreen mode

Key decisions:

  1. skipToken over enabled: false — Type-safe conditional queries. When filter is undefined, the query doesn't run.

  2. keepPreviousData — When changing pages or filters, the old data stays visible while new data loads. No loading spinners on pagination.

  3. Three loading statesisLoading (first load), isFetching (any fetch), isRefetching (manual refetch). Each drives different UI:

    • isLoading → Show skeleton
    • isFetching → Show subtle spinner in toolbar
    • isRefetching → Show "Refreshing..." text
  4. useMemo on return — Prevents unnecessary re-renders in consuming components. Without this, every TanStack Query internal state change creates a new object reference.

  5. isEmpty computed property — Components don't need to figure out empty state logic themselves.


Pattern #3: Zod Schemas with i18n Validation Messages

Most projects hardcode English validation messages. In a multi-language app, that's a non-starter. Here's how we solve it:

// src/features/tickets/schema/tickets.schema.ts

import type { TFunction } from 'i18next';
import { z } from 'zod';

export const createTicketSchema = (t: TFunction) => {
  return z.object({
    title: z
      .string({ message: t('tickets.fields.title.validation.required') })
      .min(3, t('tickets.fields.title.validation.minLength', { min: 3 })),

    workspaceId: z
      .string({ message: t('tickets.fields.workspace.validation.required') })
      .min(1, t('tickets.fields.workspace.validation.required')),

    priority: z.nativeEnum(TicketPriority, {
      message: t('tickets.fields.priority.validation.required'),
    }),

    description: z.string().optional(),
    deadline: z.string().optional(),
  });
};

export type TicketCreateSchema = z.infer<ReturnType<typeof createTicketSchema>>;
Enter fullscreen mode Exit fullscreen mode
// Usage in component
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useTranslation } from 'react-i18next';

function CreateTicketForm() {
  const { t } = useTranslation();

  const form = useForm<TicketCreateSchema>({
    resolver: zodResolver(createTicketSchema(t)),
    defaultValues: {
      title: '',
      workspaceId: '',
      priority: 'MEDIUM',
    },
  });

  // ...
}
Enter fullscreen mode Exit fullscreen mode

The key insight: Zod schemas are factory functions that receive t(). This means:

  • Validation messages are translated in real-time
  • When users switch languages, form errors update immediately
  • The schema factory pattern is fully type-safe

Translation file structure:

{
  "tickets": {
    "fields": {
      "title": {
        "label": "Ticket title",
        "placeholder": "Enter ticket title...",
        "validation": {
          "required": "Title is required",
          "minLength": "Title must be at least {{min}} characters"
        }
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Pattern #4: Type-Safe Enums Without the enum Keyword

TypeScript's enum has well-known issues (tree-shaking, type widening, runtime objects). We use const as const instead:

// src/types/enums.ts

export const TicketPriority = {
  Low: 'LOW',
  Medium: 'MEDIUM',
  High: 'HIGH',
  Urgent: 'URGENT',
} as const;

export type TicketPriority = (typeof TicketPriority)[keyof typeof TicketPriority];
// Result: 'LOW' | 'MEDIUM' | 'HIGH' | 'URGENT'

export const TicketPriorityValues = Object.values(TicketPriority);
// Result: ['LOW', 'MEDIUM', 'HIGH', 'URGENT']


export const UserRole = {
  Admin: 'ADMIN',
  Manager: 'MANAGER',
  Lead: 'LEAD',
  Member: 'MEMBER',
  Viewer: 'VIEWER',
} as const;

export type UserRole = (typeof UserRole)[keyof typeof UserRole];
export const UserRoleValues = Object.values(UserRole);
Enter fullscreen mode Exit fullscreen mode

Why this works better:

  • Fully tree-shakeable
  • TicketPriority works as both a value (object) and a type (union)
  • TicketPriorityValues gives you an iterable array for dropdowns/selects
  • Works seamlessly with Zod's z.nativeEnum()

Pattern #5: Responsive Modal — One Component, Two UIs

On desktop, users expect centered modals. On mobile, bottom drawers feel more natural. Our Modal component handles both:

// src/components/common/modal.tsx (simplified)

import { useMediaQuery } from '@/hooks/use-media-query';
import * as Dialog from '@radix-ui/react-dialog';
import { Drawer } from 'vaul';

export function Modal({ children, ...props }) {
  const isDesktop = useMediaQuery('(min-width: 768px)');

  if (isDesktop) {
    return <Dialog.Root {...props}>{children}</Dialog.Root>;
  }

  return <Drawer.Root {...props}>{children}</Drawer.Root>;
}

// Matching sub-components for each context
export function ModalContent({ children, className, ...props }) {
  const isDesktop = useMediaQuery('(min-width: 768px)');

  if (isDesktop) {
    return (
      <Dialog.Content className={cn('...dialog-styles', className)} {...props}>
        {children}
      </Dialog.Content>
    );
  }

  return (
    <Drawer.Content className={cn('...drawer-styles', className)} {...props}>
      {children}
    </Drawer.Content>
  );
}
Enter fullscreen mode Exit fullscreen mode

Usage is identical regardless of screen size:

<Modal open={isOpen} onOpenChange={setIsOpen}>
  <ModalContent className="md:min-w-2xl">
    <ModalHeader>
      <ModalTitle>{t('tickets.create.title')}</ModalTitle>
    </ModalHeader>
    <ModalBody>
      <TicketForm />
    </ModalBody>
    <ModalFooter>
      <Button onClick={handleSubmit}>{t('common.save')}</Button>
    </ModalFooter>
  </ModalContent>
</Modal>
Enter fullscreen mode Exit fullscreen mode

Pattern #6: Axios Interceptors for Auth

Token management is handled at the HTTP layer, not in every API call:

// src/plugins/axios.ts (simplified)

import axios from 'axios';
import { tokenUtils } from '@/utils/token';
import { refreshTokenFn } from '@/services/refresh-token';

export const axiosClient = axios.create({
  baseURL: import.meta.env.VITE_API_URL,
});

// Request: Inject token
axiosClient.interceptors.request.use((config) => {
  const token = tokenUtils.getAccessToken();
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
});

// Response: Handle 401 with token refresh
axiosClient.interceptors.response.use(
  (response) => response,
  async (error) => {
    const originalRequest = error.config;

    if (error.response?.status === 401 && !originalRequest._retry) {
      originalRequest._retry = true;
      await refreshTokenFn();
      return axiosClient(originalRequest);
    }

    return Promise.reject(error);
  }
);
Enter fullscreen mode Exit fullscreen mode

This means:

  • Services never deal with tokens
  • 401 errors automatically trigger token refresh
  • Failed requests are retried transparently
  • Components are completely unaware of auth headers

Pattern #7: i18n That Scales to 4+ Languages

With 4 languages and 8 features, translation management could become chaotic. Here's our structure:

src/locales/
├── en/
│   ├── common.json          # Shared: "Save", "Cancel", "Delete"...
│   ├── navigation.json      # Menu items
│   ├── features/
│   │   ├── auth.json
│   │   ├── tickets.json
│   │   ├── workspaces.json
│   │   ├── members.json
│   │   ├── groups.json
│   │   ├── divisions.json
│   │   └── overview.json
│   └── index.ts             # Auto-merge all files
├── ru/                      # Same structure
├── es/                      # Same structure
└── fr/                      # Same structure
Enter fullscreen mode Exit fullscreen mode

The index.ts auto-merge:

// src/locales/en/index.ts
import common from './common.json';
import navigation from './navigation.json';
import auth from './features/auth.json';
import tickets from './features/tickets.json';
// ... all features

export default {
  ...common,
  ...navigation,
  ...auth,
  ...tickets,
  // ...
};
Enter fullscreen mode Exit fullscreen mode

Key rules:

  1. No hardcoded strings — Every visible text uses t('key')
  2. common.json for shared text — "Save", "Cancel", "Loading..." live here once
  3. Feature-scoped keystickets.fields.title.label, not titleLabel
  4. Interpolation over concatenation"{{count}} tickets" not count + " tickets"

Pattern #8: Manual Code Splitting

Instead of relying on Vite's automatic chunking (which creates unpredictable splits), we define chunks explicitly:

// vite.config.ts
export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          'vendor-react': ['react', 'react-dom', 'react-router'],
          'vendor-ui': [
            '@radix-ui/react-dialog',
            '@radix-ui/react-dropdown-menu',
            '@radix-ui/react-popover',
            '@radix-ui/react-select',
            '@radix-ui/react-tooltip',
            // ... other Radix packages
          ],
          'vendor-data': [
            '@tanstack/react-table',
            '@tanstack/react-query',
          ],
          'vendor-forms': [
            'react-hook-form',
            'zod',
            '@hookform/resolvers',
          ],
          'vendor-utils': [
            'clsx',
            'tailwind-merge',
            'class-variance-authority',
            'date-fns',
            'axios',
            'lucide-react',
          ],
        },
      },
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

Benefits:

  • Predictable chunk sizes
  • Vendor chunks are cached long-term (they rarely change)
  • Feature code changes don't invalidate vendor cache
  • Easy to analyze bundle composition

The Provider Hierarchy

Order matters. Here's the full nesting:

<StrictMode>
  <ThemeProvider>              {/* Dark/Light mode */}
    <PageTitleProvider>        {/* Document title management */}
      <Suspense fallback={<AppLoader />}>  {/* Code splitting */}
        <App>
          <QueryClientProvider>  {/* TanStack Query cache */}
            <Toaster />          {/* Toast notifications */}
            <RouterProvider />   {/* React Router */}
          </QueryClientProvider>
        </App>
      </Suspense>
    </PageTitleProvider>
  </ThemeProvider>
</StrictMode>
Enter fullscreen mode Exit fullscreen mode

Why this order:

  • ThemeProvider wraps everything — even the loading state should respect theme
  • PageTitleProvider is above Suspense — title updates don't need the app loaded
  • QueryClientProvider is inside App — query client is created once in the component
  • Suspense catches lazy-loaded route components

Data Table System

The reusable DataTable component is one of the most complex shared pieces, built on TanStack Table v8:

src/components/data-table/
├── data-table.tsx           # Main wrapper (renders <table>)
├── toolbar.tsx              # Search + filters + actions
├── column-header.tsx        # Sortable headers
├── pagination.tsx           # Page navigation
├── data-export.tsx          # Export to Excel/CSV
├── table-settings.tsx       # Column visibility toggles
├── resizer.tsx              # Column resize handles
├── hooks/
│   └── use-data-table.ts   # Table state management
├── utils/
│   └── helpers.ts
└── types.ts                 # DataTable type definitions
Enter fullscreen mode Exit fullscreen mode

Every feature's table reuses this system with minimal boilerplate:

import { DataTable } from '@/components/data-table/data-table';

function TicketsTable() {
  const { tickets, total, isLoading } = useTicketList(filter);

  return (
    <DataTable
      data={tickets}
      columns={ticketColumns}
      totalCount={total}
      isLoading={isLoading}
      toolbar={<TicketsToolbar />}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

Dark Mode — The Right Way

Every component supports dark mode through Tailwind's dark: prefix:

<div className="
  bg-white dark:bg-gray-900/50
  border-gray-200 dark:border-gray-800
  text-gray-900 dark:text-white
">
  <p className="text-gray-600 dark:text-gray-400">
    Secondary text
  </p>
</div>
Enter fullscreen mode Exit fullscreen mode

The ThemeProvider handles system preference detection and manual toggle. Theme choice persists in localStorage.


Terminal-Style Modal Headers

One design detail I'm particularly happy with — feature modals use decorative terminal-style headers with macOS dots and color-coded gradients:

<ModalHeader className="relative overflow-hidden border-b bg-white p-0 dark:bg-gray-900/50">
  {/* Grid pattern background */}
  <div className="pointer-events-none absolute inset-0 bg-[linear-gradient(...)]" />

  {/* Color gradient overlay */}
  <div className="pointer-events-none absolute inset-0 bg-linear-to-br from-emerald-500/10 via-transparent to-teal-500/10" />

  <div className="relative px-4 py-3 sm:px-6 sm:py-5">
    {/* macOS-style terminal dots */}
    <div className="mb-2 flex items-center gap-2 sm:mb-4">
      <div className="flex gap-1.5">
        <div className="h-2 w-2 rounded-full bg-red-400 sm:h-3 sm:w-3" />
        <div className="h-2 w-2 rounded-full bg-yellow-400 sm:h-3 sm:w-3" />
        <div className="h-2 w-2 rounded-full bg-green-400 sm:h-3 sm:w-3" />
      </div>
      <code className="ml-2 rounded-md bg-gray-100 px-2 py-0.5 text-xs dark:bg-gray-800">
        ticket.create()
      </code>
    </div>

    {/* Icon + Title */}
    <div className="flex items-center gap-3">
      <div className="flex h-10 w-10 items-center justify-center rounded-xl bg-linear-to-br from-emerald-500 to-teal-600 shadow-lg">
        <PlusIcon className="h-5 w-5 text-white" />
      </div>
      <ModalTitle>{t('tickets.create.title')}</ModalTitle>
    </div>
  </div>
</ModalHeader>
Enter fullscreen mode Exit fullscreen mode

Color schemes by action:

  • Create/Add: emerald-500teal-600
  • Edit/Update: blue-500indigo-600
  • Delete: red-500rose-600
  • View/Info: violet-500purple-600

This small touch makes the app feel polished and gives users immediate visual context about what action they're performing.


What I'd Do Differently

1. Start with Zustand earlier. We use Context for auth and sidebar, which works fine but creates unnecessary re-renders. Zustand with useShallow would be more efficient.

2. Co-locate pages with features. Having a separate pages/ directory creates indirection. Each feature could export its own page components.

3. Adopt a monorepo structure. Shared components (data-table, form-inputs, modal) could be a separate package, making them reusable across projects.


Key Takeaways

  1. Feature-based structure scales. Group files by feature, not by type. When you work on "tickets", everything is in one folder.

  2. Service layer = pure API calls. No business logic, no error handling. Just axios calls that return data.

  3. TanStack Query key factories prevent stale data bugs. Centralize keys, use skipToken, return memoized objects.

  4. Zod schema factories with t() give you i18n validation for free. No message duplication across languages.

  5. const as const > enum for tree-shaking and type safety.

  6. Responsive modals (Dialog on desktop, Drawer on mobile) improve UX with zero API changes.

  7. Manual code splitting gives you predictable, cacheable chunks.

  8. Biome over ESLint + Prettier — one tool, faster, less config.


Resources


350 files later, I still open any feature folder and know exactly where everything is. That's the whole point.

Your project won't look like this — and it shouldn't. Steal what works, ignore what doesn't. Happy shipping.

Website

Top comments (2)

Collapse
 
nedcodes profile image
Ned C

The Zod schema factory pattern for i18n is really clever. I've seen too many codebases where validation messages are hardcoded in English and then someone bolts on translations as an afterthought, which leads to a mess of duplicate logic.

One thing I'd add: the query key factory pattern becomes even more valuable when you start doing optimistic updates. Having centralized keys means your onMutate and onSettled callbacks can invalidate exactly the right queries without guessing. Without that, optimistic updates in a large app become fragile fast.

Also agree on the Zustand point. Context works fine for low-frequency state (auth, theme), but once you need fine-grained subscriptions, the re-render cost adds up.

Collapse
 
matkarimov099 profile image
MATKARIM MATKARIMOV

@nedcodes — Great insights, thank you!
You're absolutely right about optimistic updates. That's actually one of the main reasons I adopted the query key factory pattern in the first place. When you have centralized keys like projectKeys.list() or projectKeys.detail(id), your onMutate / onSettled callbacks become predictable and maintainable. Without it, I've seen teams resort to ueryClient.invalidateQueries() with no arguments — basically nuking the entire cache — because they couldn't track which keys to invalidate. The factory pattern eliminates that guesswork entirely.
And yes, the Zod + i18n factory was born from exactly the pain you described — bolting translations onto hardcoded messages after the fact. The createSchema(t) approach makes validation messages reactive to language changes from day one, with zero duplication.
Appreciate the thoughtful comment! These are exactly the kinds of real-world considerations that make or break architecture at scale.