EEPNext.js Template
The Layers

Client Layer

How the client side is structured. Components, SWR hooks, and Zustand

The client layer is split across three folders:

FolderJob
components/Render UI
hooks/Fetch data via SWR
store/Manage client-side state via Zustand

Components

Components are purely presentational. They don't fetch, they don't mutate, and they don't own business logic:

// ✅ UI logic - deciding what to render based on state. Fine.
function InvoiceRow({ invoice }: InvoiceRowProps) {
  const isOverdue = invoice.dueDate < new Date();

  return (
    <tr className={isOverdue ? 'text-red-600' : ''}>
      <td>{invoice.reference}</td>
      <td>{formatCurrency(invoice.amount)}</td>
    </tr>
  );
}
/**
 * ❌ Business logic - calculating whether an invoice is overdue belongs in the service layer
 * not the component. The component shouldn't know the business rule,
 * only how to display the result of it.
 */
function InvoiceRow({ invoice }: InvoiceRowProps) {
  const isOverdue =
    invoice.status !== 'paid' &&
    invoice.dueDate < new Date() &&
    !invoice.gracePeriodActive;
  // ...
}

The test for whether logic belongs in a component: Could this rule change based on business decisions alone (not UI decisions)? If yes, it's business logic and belongs in the server layer or a hook.

Structure rules:

  • Single-file components are flat kebab-name.tsx. Promote to a folder only when the component grows real parts: sub-components, split types, helpers.

    theme-toggle.tsx// flat - single file, no sub-parts
    index.tsx// entry point - composes all sub-components
    nav-main.tsx// sub-component - main nav links
    nav-user.tsx// sub-component - user avatar + menu
    settings-items.tsx// sub-component - settings links
  • components/ui/ - shadcn primitives

  • components/common/ - shared across features (layout, error states, auth):

    theme-toggle.tsx
    auth.tsx
    error-display.tsx
  • components/features/ - domain-coupled components that only make sense within a specific feature:

    counter.tsx// flat - small, no sub-parts
    posts.tsx// flat - small, no sub-parts
    index.tsx// public entry point - ExampleItems
    form.tsx// ItemFormValues, ItemFormFields, CreateForm
    item-row.tsx// ItemList, ViewRow, EditRow

example-items earned a folder here because it grew three distinct concerns: shared form fields and types, the create form mutation, and the list/row display with inline edit mode.


Data fetching with SWR

SWR handles loading state, error state, caching, deduplication, and revalidation out of the box. All client-side fetching goes through SWR hooks in hooks/, one hook per data domain.

SWR types error as any - always narrow it to Error before returning.

// hooks/use-example-items.ts
export function useExampleItems(): UseExampleItemsReturn {
  const { data, isLoading, error, mutate } =
    useSWR<ExampleItemsResponse>('/api/example-items');

  return {
    items: data?.exampleItems,
    isLoading,
    // SWR types error as `any` - narrow to Error so callers get a stable shape.
    error: error instanceof Error ? error : undefined,
    mutate,
  };
}

The mutate function is exposed so components can apply optimistic updates within the UI, pass { optimisticData, rollbackOnError: true }and SWR reverts automatically on failure.

The alternative to using SWR (useEffect + useState) means writing a dedicated version of it yourself, which is much more convoluted:

// Without SWR - ~50 lines to fetch one resource
export function useItem(id: string) {
  const [isLoading, setIsLoading] = useState(true);
  const [item, setItem] = useState<Item | null>(null);
  const [error, setError] = useState<Error | null>(null);

  const fetchItem = useCallback(async () => {
    if (!id) {
      setError(new Error('ID is required'));
      setIsLoading(false);
      return;
    }
    try {
      setIsLoading(true);
      setError(null);
      const response = await fetch(`/api/items/${encodeURIComponent(id)}`);
      if (!response.ok) throw new Error(`Request failed: ${response.status}`);
      setItem(await response.json());
    } catch (err) {
      setError(err as Error);
      setItem(null);
    } finally {
      setIsLoading(false);
    }
  }, [id]);

  useEffect(() => {
    fetchItem();
  }, [fetchItem]);

  return { isLoading, item, error, refetch: fetchItem };
}

Every hook then repeats this boilerplate. With SWR the same hook is a handful of lines.

TanStack Query is the other alternative option and is worth looking at if you need more granular cache control.

An SWR hook should in most cases call an internal /api/* route:

  • useExampleItemsGET /api/example-items → the DB service.
  • usePostsGET /api/posts → the posts integration service, which fetches JSONPlaceholder server-side.

usePosts going through a route + service is illustrative. JSONPlaceholder is a public no-auth GET, so realistically the hook could hit it directly and skip the server layer entirely. It's wired the long way purely to demonstrate the server-integration pattern end to end. The rule for real code: reach for a classes/services/ class only when there's auth, a secret, or response shaping to own - otherwise the client → SWR → fetcher path is enough. See Server Layer → When do you need a service class?


State management with Zustand

Reach for a store when the same piece of client state has to be read or updated by components that don't share a direct parent-child line. If the state stays inside one component or flows one level down, plain useState is enough, don't reach for a store.

The alternative to a store is prop drilling threading the value and its setter down through every intermediate component that doesn't itself care about it just to reach the one that does. That couples unrelated components to the shape of the state and turns a small change into an edit across the whole chain. A store lets any component subscribe directly to the slice it needs, no intermediaries.

Zustand stores live in store/, one file per domain. The state shape and actions are always defined as separate types and intersected.

The counter below is a reference example, not a real need. Its state lives in one component, so by the rule above it should just be useState. It's a store purely to demonstrate the pattern in a copy-pasteable shape, don't take it as a cue to reach for Zustand whenever you have a counter.

// store/counter-store.ts
type CounterState = {
  count: number;
};

type CounterActions = {
  increment: () => void;
  decrement: () => void;
  reset: () => void;
};

export type CounterStore = CounterState & CounterActions;

// TypeScript enforces that every state field has a default here.
// Add a field to CounterState and the compiler errors until you add it here too.
export const defaultCounterState: CounterState = {
  count: 0,
};

export const useCounterStore = create<CounterStore>()((set) => ({
  ...defaultCounterState,
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 })),
  // Safe reset - only resets state fields, never touches actions.
  reset: () => set(defaultCounterState),
}));

Components subscribe to only the slices they need, this prevents unnecessary re-renders when unrelated state changes:

// Correct - subscribe to individual slices
const count = useCounterStore((state) => state.count);
const increment = useCounterStore((state) => state.increment);

// Wrong - subscribes to the whole store, re-renders on any change
const store = useCounterStore();

Let's also just briefly touch on the more simplistic type layout alternative and why this can be sub-optimal:

// Simpler, but loses two guarantees
export type CounterStore = {
  count: number;
  increment: () => void;
  decrement: () => void;
  reset: () => void;
};

The problem here is you can no longer derive a correctly-typed default state. With a merged type, defaultCounterState has to be typed as Partial<CounterStore> (actions aren't state) or cast with as (slightly unsafe). The reset implementation is the other concrete win. With the merged type you'd have to manually list the fields to reset:

// Brittle - easy to forget a field when state grows
reset: () => set({ count: 0, someNewField: '' }),

With the split, reset is always correct by construction, add a field to CounterState and its default is automatically included:

// Always resets exactly the right fields, nothing more
reset: () => set(defaultCounterState),

On this page