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:
- Code Standards -- language-level conventions, function design
- Testing Strategy -- testing philosophy, trophy model
- Security -- threat modeling, security principles
- Developer Experience -- tooling, environment setup
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:
- Type definitions (props interface)
- Component function (named export)
- Hooks at the top of the function body
- Derived state and computations
- Event handlers
- Early returns (loading, error, empty states)
- 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:
| Scope | Solution |
|---|---|
| Used by one component only | useState / useReducer inside that component |
| Shared by a parent and its direct children | Lift to the parent, pass via props |
| Shared by a subtree of related components | React 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:
- Backend exports its OpenAPI spec as JSON (e.g.,
swagger/openapi.jsonor via a live endpoint). - A code generation tool reads the spec and produces typed client code.
- 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
onSuccessinuseMutationfor invalidation, notonSettled-- you only want to refetch on success. - For optimistic updates (showing the result before the server confirms), use
onMutateto snapshot the previous cache, apply the optimistic value, and roll back inonError. 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
| Type | Suffix | Example |
|---|---|---|
| Component | *.component.tsx | user-profile.component.tsx |
| Hook | *.hook.ts | use-auth.hook.ts |
| Context | *.context.tsx | auth.context.tsx |
| Types | *.type.ts | user.type.ts |
| Utility | *.util.ts | date-format.util.ts |
| Query/API | *.queries.ts | user.queries.ts |
| Test | *.test.ts / *.test.tsx | user-card.test.tsx |
| Story | *.stories.tsx | user-card.stories.tsx |
Directory naming
- Folders:
kebab-case--auth-form/,user-profile/,shared/ - Component files:
kebab-casewith suffix --user-card.component.tsx - Barrel exports:
index.tsat 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:
| Priority | Query | When to use |
|---|---|---|
| 1st | getByRole | Buttons, links, headings, form controls -- anything with an ARIA role |
| 2nd | getByLabelText | Form inputs with associated labels |
| 3rd | getByPlaceholderText | Inputs where placeholder is the only visible text |
| 4th | getByText | Non-interactive text content |
| Last resort | getByTestId | Elements 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 handleonKeyDownfor Enter and Space. Just use<button>.<a>without anhreffor 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
tabindexvalues 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 therequiredattribute) and have a visual indicator. - Form submission errors are announced to screen readers using a live region (
role="alert"oraria-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 costin your editor. - Prefer tree-shakeable libraries (ES module exports) over libraries that require importing the entire package.
- Use dynamic imports (
next/dynamicorReact.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-analyzerorrollup-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
widthandheighton images to prevent cumulative layout shift (CLS).
Rendering strategy
Choose the rendering strategy per route based on the data requirements:
| Strategy | When 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
- Bulletproof React -- Production-ready React architecture patterns
- Tao of React -- Principles for writing maintainable React applications
- Feature Sliced Design -- Frontend architecture methodology
- shadcn/ui -- Component primitives built on Radix UI and Tailwind
- Radix UI -- Accessible, unstyled component primitives
- TanStack Query -- Server state management for React
- React Testing Library -- Testing utilities that encourage accessible components
- MSW (Mock Service Worker) -- API mocking for tests and development
- Vitest -- Fast test runner with native TypeScript support
- openapi-typescript -- TypeScript type generation from OpenAPI specs
- orval -- API client generation from OpenAPI specs
- WCAG 2.1 Quick Reference -- Web accessibility guidelines
- axe browser extension -- Accessibility testing in the browser
- AirBnB JavaScript Style Guide -- JavaScript conventions reference
- Tailwind Prettier Plugin -- Automatic class sorting
- eslint-plugin-boundaries -- Enforce architectural import rules