Skip to main content

Frontend Reference

Frontend engineering at S&P means building interfaces that are fast, accessible, and maintainable across the lifetime of a client project. React with Next.js is the primary framework. TypeScript is non-negotiable. This appendix is the lookup reference for implementation patterns, project structure, and tooling conventions specific to frontend development.


Scope

This appendix covers frontend-specific implementation patterns for S&P projects. For cross-cutting practices, see the main playbook sections:

Examples use React with Next.js as reference implementation. Principles are portable -- adapt patterns for your project's stack.


Project structure -- Feature Sliced Design

Feature Sliced Design (FSD) is the standard frontend architecture at S&P. It solves the problem every growing React project hits: the flat components/ + hooks/ + utils/ structure that becomes unnavigable past 50 files. FSD replaces that with layers that have explicit dependency rules.

Layer hierarchy

Each layer can only import from layers below it. This is the fundamental rule -- it prevents circular dependencies and makes the codebase predictable.

src/
app/ # App-wide setup: providers, routing, global styles
processes/ # Complex multi-page flows (rarely needed)
pages/ # Page compositions -- assemble widgets and features
widgets/ # Large composite UI blocks (header, sidebar, dashboard panels)
features/ # User interactions (add-to-cart, auth-form, filter-toggle)
entities/ # Business objects (user, product, order) -- data + UI
shared/ # Reusable foundation: UI kit, API client, lib wrappers, config

Import direction is strictly top-down. A feature can import from entities and shared. An entity can import from shared. Shared imports from nothing inside the project. Violations of this rule are architectural debt -- they couple parts of the codebase that should be independent.

Slice structure

Each slice within a layer follows the same internal layout:

features/
auth-form/
ui/ # Components
model/ # State, types, business logic
api/ # Data fetching for this feature
lib/ # Utilities scoped to this feature
index.ts # Public API -- only export what other layers need

The index.ts file is the public contract. Other layers import from @/features/auth-form, never from @/features/auth-form/model/store. This gives you the freedom to refactor internals without breaking consumers.

Adapting FSD for Next.js App Router

Next.js requires its own app/ directory for routing. This creates a naming conflict with FSD's app/ layer. The solution is straightforward -- let Next.js own routing, but keep route files thin:

app/ # Next.js App Router (routing only)
layout.tsx # Root layout -- wraps with providers from src/app
page.tsx # Delegates to src/pages/home
dashboard/
page.tsx # Delegates to src/pages/dashboard
settings/
page.tsx # Delegates to src/pages/dashboard-settings

src/
app/ # FSD app layer: providers, global config
providers/
query-provider.tsx
theme-provider.tsx
styles/
globals.css
pages/ # FSD page compositions
home/
ui/
index.ts
dashboard/
ui/
index.ts
widgets/
features/
entities/
shared/

Route files in app/ should be entry points, not logic containers. A typical route file looks like this:

// app/dashboard/page.tsx
import { DashboardPage } from '@/pages/dashboard';

export default function Page() {
return <DashboardPage />;
}

Default exports are required by Next.js for route files. This is the one exception to the named-export preference described in the Component Patterns section below.

Adapting FSD for React + Vite

When the project does not need SSR, server components, or Next.js-specific features -- a pure SPA admin panel, an internal tool, a client-side dashboard -- use React with Vite. The FSD architecture is identical. The only difference is routing: use react-router or @tanstack/router instead of file-based routing.

src/
app/
providers/
router.tsx # Route definitions (react-router or @tanstack/router)
index.tsx # App entry point
pages/
widgets/
features/
entities/
shared/

Import rules enforcement

Configure path aliases and an ESLint rule to enforce the import hierarchy. Without automated enforcement, import rules erode within weeks.

{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
}
}

Use eslint-plugin-boundaries or a custom ESLint rule to flag imports that violate layer ordering. A feature importing from a widget is a build error, not a suggestion.

Polyglot note: Angular's module system maps naturally to FSD layers -- SharedModule is the shared layer, feature modules are the features layer. Vue composables map to shared/lib, and Pinia stores map to model/ segments within each slice.


Component patterns

Structure

Components follow a consistent internal layout. This order exists because it maps to how someone reads and understands a component -- what it accepts, what it does, and what it renders:

  1. Type definitions (props interface)
  2. Component function (named export)
  3. Hooks at the top of the function body
  4. Derived state and computations
  5. Event handlers
  6. Early returns (loading, error, empty states)
  7. Main render

One component per file. The file exports a single component. If a component needs a sub-component that is only used internally, it goes in the same directory as a separate file, not in the same file.

Example: UserCard

interface UserCardProps {
userId: string;
onSelect?: (user: User) => void;
}

export function UserCard({ userId, onSelect }: UserCardProps) {
const { data: user, isLoading } = useUser(userId);

const fullName = user ? `${user.firstName} ${user.lastName}` : '';

function handleClick() {
if (user && onSelect) {
onSelect(user);
}
}

if (isLoading) {
return <UserCardSkeleton />;
}

if (!user) {
return null;
}

return (
<button onClick={handleClick} className="...">
<span>{fullName}</span>
</button>
);
}

Why named exports, not default exports. Named exports are easier to refactor (rename propagates automatically), easier to grep, and prevent the problem where the same component has three different names across the codebase because each consumer chose a different import alias. The exception is Next.js route files (page.tsx, layout.tsx) where the framework requires default exports.

Custom hooks

Extract logic into a custom hook when it is used by more than one component, or when it makes a complex component easier to read by separating concerns. Name hooks with the use prefix and put them in *.hook.ts files.

// use-debounced-value.hook.ts
export function useDebouncedValue<T>(value: T, delay: number): T {
const [debounced, setDebounced] = useState(value);

useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);

return debounced;
}

Hooks colocate with the layer that owns them. A hook used only by one feature lives in that feature's slice. A hook used across multiple features or widgets lives in shared/lib/hooks/.

State colocation

State lives as close as possible to where it is used. This is the single most impactful rule for keeping React applications maintainable. Lifting state too high causes unnecessary re-renders and makes components harder to understand in isolation.

Decision ladder for where state belongs:

ScopeSolution
Used by one component onlyuseState / useReducer inside that component
Shared by a parent and its direct childrenLift to the parent, pass via props
Shared by a subtree of related componentsReact Context scoped to that subtree
Truly global (auth state, theme, feature flags)Zustand or Jotai at the app level
Server data (API responses, cache)TanStack Query -- this is not "state", it's cache

Do not reach for global state management until local state and prop drilling have proven inadequate. The threshold for "inadequate" is roughly 3-4 levels of prop threading through components that do not use the props themselves.

Polyglot note: Angular achieves the same colocation principle with component-scoped services and signals. Vue uses ref()/reactive() for local state and Pinia stores for shared state -- the decision ladder is the same, only the API differs.


Styling

Tailwind patterns

Use Tailwind utility classes directly in JSX. Do not create CSS files or CSS modules for component-specific styles. The cn() utility (from clsx + tailwind-merge) handles conditional class composition:

import { cn } from '@/shared/lib/utils';

export function Badge({ variant, children }: BadgeProps) {
return (
<span
className={cn(
'inline-flex items-center rounded-md px-2 py-1 text-xs font-medium',
variant === 'success' && 'bg-green-50 text-green-700',
variant === 'error' && 'bg-red-50 text-red-700',
variant === 'neutral' && 'bg-gray-50 text-gray-600',
)}
>
{children}
</span>
);
}

Class ordering convention: Follow the pattern: layout (flex, grid, block) then sizing (w-, h-, p-, m-) then typography (text-, font-) then visual (bg-, border-, rounded-, shadow-) then state (hover:, focus:, disabled:). Consistent ordering makes classes scannable in review. Use the Tailwind Prettier plugin to automate this.

shadcn/ui conventions

shadcn/ui is the component foundation for buttons, inputs, dialogs, dropdowns, tables, and other standard UI elements. shadcn is not a dependency you install -- it generates component source code into your project, which means you own the code and can modify it directly.

How S&P uses shadcn:

  • Install shadcn components into src/shared/ui/ as the base UI kit.
  • Customize through Tailwind classes and the cn() utility. Do not override component internals unless the Tailwind API is genuinely insufficient.
  • When the project needs a component shadcn does not provide, build it using Radix UI primitives and Tailwind for consistency. Radix is the accessibility layer underneath shadcn -- using it directly ensures consistent keyboard navigation and ARIA patterns.
  • Do not install a second component library alongside shadcn unless there is a specific capability gap that shadcn plus Radix cannot cover. Two UI libraries create two design languages, which creates visual inconsistency and doubles the surface area for bugs.

Extending, not forking. When a shadcn component needs project-specific behavior, wrap it in a project-level component rather than editing the generated source. This way, updating shadcn later does not require resolving merge conflicts in your customizations.

// src/shared/ui/status-badge.tsx -- wraps shadcn Badge with project-specific variants
import { Badge } from '@/shared/ui/badge';
import { cn } from '@/shared/lib/utils';

const statusStyles = {
active: 'bg-green-50 text-green-700 border-green-200',
inactive: 'bg-gray-50 text-gray-600 border-gray-200',
error: 'bg-red-50 text-red-700 border-red-200',
} as const;

type Status = keyof typeof statusStyles;

interface StatusBadgeProps {
status: Status;
children: React.ReactNode;
}

export function StatusBadge({ status, children }: StatusBadgeProps) {
return (
<Badge className={cn(statusStyles[status])}>
{children}
</Badge>
);
}

Polyglot note: Angular projects use Angular Material or PrimeNG for equivalent component primitives. The principle is the same -- choose one component library, extend it through composition, do not mix libraries.


API consumption

Type generation from OpenAPI

The backend generates an OpenAPI specification via @nestjs/swagger. The frontend consumes it to auto-generate TypeScript types and API client code. This eliminates the class of bugs where frontend and backend disagree on request/response shapes.

The pipeline:

  1. Backend exports its OpenAPI spec as JSON (e.g., swagger/openapi.json or via a live endpoint).
  2. A code generation tool reads the spec and produces typed client code.
  3. Frontend imports the generated types -- full autocomplete, compile-time validation against the actual API contract.

Recommended tools:

  • openapi-typescript -- Generates TypeScript types from OpenAPI specs. Lightweight, no runtime dependency. Use this when you want types only and will write fetch calls manually or with a thin wrapper.
  • orval -- Generates typed API clients with TanStack Query hooks, Axios calls, or fetch wrappers directly from the spec. More opinionated, but eliminates boilerplate for data fetching.
{
"scripts": {
"api:generate": "openapi-typescript ../api/swagger/openapi.json -o src/shared/api/schema.d.ts"
}
}

Run api:generate after backend schema changes, or hook it into the API's build step. The generated file is committed to the repository -- it is a build artifact, not a runtime dependency. Committing it means frontend developers get type safety without needing the API running locally.

Data fetching patterns

TanStack Query (React Query) is the default for server state management. It handles caching, background refetching, stale-while-revalidate, and request deduplication -- problems that are tedious and error-prone to solve with raw useEffect + useState.

Query key conventions:

Use a factory pattern for query keys to keep them consistent and easy to invalidate:

// src/entities/user/api/user.queries.ts
export const userKeys = {
all: ['users'] as const,
lists: () => [...userKeys.all, 'list'] as const,
list: (filters: UserFilters) => [...userKeys.lists(), filters] as const,
details: () => [...userKeys.all, 'detail'] as const,
detail: (id: string) => [...userKeys.details(), id] as const,
};

// Usage
const { data } = useQuery({
queryKey: userKeys.detail(userId),
queryFn: () => fetchUser(userId),
});

// Invalidation -- invalidate all user queries after a mutation
queryClient.invalidateQueries({ queryKey: userKeys.all });

Cache invalidation patterns:

  • After a mutation that creates or updates an entity, invalidate the relevant query keys. Do not manually update the cache unless the mutation response contains the complete updated entity.
  • Use onSuccess in useMutation for invalidation, not onSettled -- you only want to refetch on success.
  • For optimistic updates (showing the result before the server confirms), use onMutate to snapshot the previous cache, apply the optimistic value, and roll back in onError. Reserve optimistic updates for interactions where perceived latency matters (toggling a favorite, reordering a list) -- not for every mutation.

Polyglot note: Angular projects use HttpClient with RxJS operators for data fetching -- the caching and invalidation patterns differ mechanically but the concepts (query keys, stale data, optimistic updates) apply. Vue projects can use TanStack Query directly via @tanstack/vue-query with the same API.


Naming conventions

Consistent file naming makes the codebase navigable without memorizing project-specific conventions. These patterns apply across all frontend projects.

File suffixes

TypeSuffixExample
Component*.component.tsxuser-profile.component.tsx
Hook*.hook.tsuse-auth.hook.ts
Context*.context.tsxauth.context.tsx
Types*.type.tsuser.type.ts
Utility*.util.tsdate-format.util.ts
Query/API*.queries.tsuser.queries.ts
Test*.test.ts / *.test.tsxuser-card.test.tsx
Story*.stories.tsxuser-card.stories.tsx

Directory naming

  • Folders: kebab-case -- auth-form/, user-profile/, shared/
  • Component files: kebab-case with suffix -- user-card.component.tsx
  • Barrel exports: index.ts at the slice boundary

The kebab-case convention for folders avoids case-sensitivity issues across operating systems (macOS is case-insensitive by default, Linux is not -- this has caused real CI failures on S&P projects).


Testing setup

React Testing Library + MSW

Render components using React Testing Library, intercept network requests with MSW (Mock Service Worker), and interact with the component the way a user would -- clicking buttons, filling forms, reading what appears on screen.

What to test at this level:

  • User flows within a page or feature (fill form, submit, see confirmation)
  • Component behavior with different API responses (success, error, loading, empty state)
  • Conditional rendering based on user roles or feature flags
  • Form validation feedback visible to the user
it('shows validation error when email is invalid', async () => {
render(<SignupForm />);

await userEvent.type(screen.getByLabelText('Email'), 'not-an-email');
await userEvent.click(screen.getByRole('button', { name: 'Sign up' }));

expect(screen.getByRole('alert')).toHaveTextContent('Enter a valid email address');
});

Test runner configuration

Vitest is the recommended test runner for React projects. It shares Vite's transform pipeline, which means near-instant startup and native TypeScript/JSX support without additional configuration.

// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import tsconfigPaths from 'vite-tsconfig-paths';

export default defineConfig({
plugins: [react(), tsconfigPaths()],
test: {
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
globals: true,
css: false,
},
});
// src/test/setup.ts
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';

afterEach(() => {
cleanup();
});

For Next.js projects using server components or other Next.js-specific features in tests, use @testing-library/react with next/jest configuration. Vitest remains the runner -- only the transform configuration differs.

Query priority as accessibility signal

React Testing Library's query priority is deliberately designed to reflect how users interact with elements. Following this priority is not just a testing best practice -- it is a direct signal of your component's accessibility:

PriorityQueryWhen to use
1stgetByRoleButtons, links, headings, form controls -- anything with an ARIA role
2ndgetByLabelTextForm inputs with associated labels
3rdgetByPlaceholderTextInputs where placeholder is the only visible text
4thgetByTextNon-interactive text content
Last resortgetByTestIdElements with no accessible role or text

If you cannot query an element without getByTestId, that is usually an accessibility problem worth fixing, not a testing limitation to work around. A button that needs a test ID to be found in tests also cannot be found by screen readers.

Polyglot note: Testing Library works with Angular (@testing-library/angular) and Vue (@testing-library/vue) with the same query API and the same priority rules. The accessibility signal applies identically regardless of framework.


Accessibility

WCAG 2.1 AA is the baseline for all S&P frontend projects. This is not aspirational -- it is the minimum standard. Accessibility failures are bugs, triaged and prioritized like any other bug.

Semantic HTML first

Use the correct HTML element before reaching for ARIA attributes. A <button> is a button. A <nav> is navigation. A <table> is tabular data. ARIA is a repair tool for situations where semantic HTML is insufficient -- it is not a replacement for using the right element in the first place.

Common mistakes to avoid:

  • <div onClick={...}> instead of <button> -- a div with a click handler is not keyboard-accessible, has no button role, and is invisible to screen readers without additional ARIA markup.
  • <div role="button"> instead of <button> -- this adds the role but you still need to handle onKeyDown for Enter and Space. Just use <button>.
  • <a> without an href for actions that do not navigate. Use <button> for actions, <a> for navigation.

Keyboard navigation

Every interactive element must be reachable and operable via keyboard:

  • All focusable elements have a visible focus indicator. Tailwind's focus-visible: utilities handle this -- do not remove the default browser focus ring without providing an alternative.
  • Tab order follows the visual layout. Do not use tabindex values greater than 0 -- they create a parallel tab order that is unpredictable for users.
  • Modal dialogs trap focus and return focus to the trigger element on close. Radix UI primitives (used by shadcn) handle this correctly -- do not bypass their focus management.
  • Custom keyboard shortcuts document themselves and do not conflict with browser or screen reader shortcuts.

Color and contrast

  • Text must meet a minimum contrast ratio of 4.5:1 against its background (3:1 for large text, which is 18pt regular or 14pt bold).
  • Do not use color as the only means of conveying information. Error states need an icon or text label in addition to red coloring. Status indicators need a shape or label alongside their color.
  • Test with browser DevTools accessibility panel or the axe browser extension.

Forms

  • Every input has an associated <label>. Placeholder text is not a label -- it disappears on focus and is not reliably announced by screen readers.
  • Error messages are programmatically associated with their input via aria-describedby.
  • Required fields are marked with aria-required="true" (or the required attribute) and have a visual indicator.
  • Form submission errors are announced to screen readers using a live region (role="alert" or aria-live="assertive").

Performance

Bundle size awareness

Every dependency added to the frontend bundle ships to every user on every page load. Treat bundle size as a first-class concern:

  • Check the bundle impact of new dependencies before adding them. Use bundlephobia or import cost in your editor.
  • Prefer tree-shakeable libraries (ES module exports) over libraries that require importing the entire package.
  • Use dynamic imports (next/dynamic or React.lazy) for components that are not needed on initial render -- modals, below-the-fold content, admin-only features.
  • Analyze the bundle periodically with @next/bundle-analyzer or rollup-plugin-visualizer.

Image optimization

  • Use Next.js <Image> component for automatic format conversion (WebP/AVIF), responsive sizing, and lazy loading.
  • Serve images from a CDN. Never commit large image assets to the repository.
  • Set explicit width and height on images to prevent cumulative layout shift (CLS).

Rendering strategy

Choose the rendering strategy per route based on the data requirements:

StrategyWhen to use
Static (SSG)Content that changes infrequently -- marketing pages, documentation, blog posts
Server-side (SSR)Pages that need fresh data on every request -- dashboards, user profiles
Client-side (CSR)Highly interactive sections that do not need SEO -- admin panels, authenticated tools
Incremental Static Regeneration (ISR)Content that changes periodically -- product listings, CMS-driven pages

Default to SSR for authenticated pages and SSG for public pages. Do not over-optimize rendering strategy during initial development -- get the feature working correctly first, then optimize the rendering path based on measured performance.


Resources