27 min read

A Modern Approach to Loosely Coupled UI Components

This document provides a comprehensive guide for building meta-framework-agnostic, testable, and boundary-controlled UI components for modern web applications.


  1. Introduction
  2. Assumptions & Prerequisites
  3. Glossary of Terms
  4. Design Principles
  5. Architecture Overview
  6. Layer Definitions
  7. Internal SDKs
  8. Folder Structure
  9. Implementation Patterns
  10. Boundary Control & Enforcement
  11. Testability
  12. Configuration
  13. Migration Guide

As web applications grow in complexity, maintaining a clean separation of concerns becomes critical. This guide presents an architecture that:

  • Decouples business logic from UI primitives
  • Abstracts framework-specific APIs for portability
  • Enforces clear boundaries between architectural layers
  • Enables comprehensive testing through dependency injection
  • Supports server-driven UI patterns common in modern applications

Whether you’re building an e-commerce platform, a content management system, or a SaaS dashboard, these patterns provide a solid foundation for scalable frontend architecture.


This guide assumes the following context. Adapt as needed for your specific situation.

AspectAssumptionAdaptable?
UI LibraryReact 18+Core patterns apply to Vue, Svelte with modifications
LanguageTypeScript (strict mode)Strongly recommended, not optional
Meta-frameworkNext.js, Remix, or similar SSR frameworkArchitecture is framework-agnostic
Build ToolVite, Webpack, or TurbopackAny modern bundler works
Package Managernpm, yarn, or pnpmNo specific requirement
PatternDescriptionRequired?
Design SystemA separate library of generic UI componentsYes
Backend-for-Frontend (BFF)A backend layer that serves UI-specific dataRecommended
Server-Driven UIBackend defines page layout and widget compositionOptional
Widget-Based ArchitectureUI composed of self-contained, configurable modulesYes

This architecture works best when:

  • Multiple teams contribute to the same application
  • Clear ownership boundaries are needed
  • Components are shared across multiple applications
  • Long-term maintainability is prioritized over short-term velocity

TermDefinition
PrimitiveA generic, reusable UI component with no business logic (e.g., Button, Card, Modal). Lives in the design system.
BlockA business-aware component that composes Primitives and adds domain-specific behavior (e.g., ProductCard, AddToCartButton).
WidgetA self-contained page section that receives configuration from the backend and composes Blocks to render a complete feature.
SDKAn internal abstraction layer that provides framework-agnostic access to cross-cutting concerns (routing, analytics, state).
TermDefinition
BFF (Backend-for-Frontend)A backend service layer specifically designed to serve the needs of a particular frontend. It aggregates data from multiple services and formats it for UI consumption.
LayoutA data structure from the BFF that defines the page structure, including SEO metadata, analytics configuration, and the list of widgets to render.
Widget PayloadThe data contract between the BFF and a specific widget, containing all information needed to render that widget.
Widget RegistryA mapping of widget type identifiers to their corresponding React components.
TermDefinition
BoundaryA defined interface between architectural layers that controls what can be imported from where.
Barrel ExportAn index.ts file that explicitly defines the public API of a module.
Dependency InjectionA pattern where dependencies are provided to a component rather than created within it.
Provider PatternUsing React Context to inject dependencies at runtime, enabling easy testing and configuration.

Components should not directly depend on meta-framework APIs (Next.js, Remix, etc.). Instead, framework-specific functionality is accessed through SDK abstractions.

Why?

  • Enables migration between frameworks without rewriting components
  • Simplifies testing by removing framework mocking
  • Allows components to be shared across applications using different frameworks

Example:

// ❌ Bad: Direct framework dependency
import { useRouter } from "next/navigation"
const router = useRouter()
router.push("/products")
// ✅ Good: SDK abstraction
import { useAppRouter } from "@sdk/router"
const router = useAppRouter()
router.push("/products")

Each architectural layer has explicit rules about what it can import. These rules are enforced through tooling, not just documentation.

Why?

  • Prevents circular dependencies
  • Makes the codebase easier to understand
  • Enables independent deployment of layers
  • Reduces unintended coupling

All external dependencies (HTTP clients, analytics, state management) are injected via providers, making components easy to test in isolation.

Why?

  • Unit tests don’t require complex mocking
  • Test behavior, not implementation details
  • Fast, reliable test execution

Each layer has one clear purpose:

  • Primitives: Visual presentation
  • Blocks: Business logic + UI composition
  • Widgets: Backend contract interpretation + page composition
  • SDKs: Cross-cutting concerns abstraction

Every module exposes its public API through a barrel file (index.ts). Internal implementation details are not importable from outside the module.

Why?

  • Enables refactoring without breaking consumers
  • Makes API surface area clear and intentional
  • Supports tree-shaking and code splitting

┌─────────────────────────────────────────────────────────────────────────┐
│ Application Shell (Next.js / Remix / Vite) │
│ • Routing, SSR/SSG, Build configuration │
│ • Provides SDK implementations │
└─────────────────────────────────────────────────────────────────────────┘
▼ provides implementations
┌─────────────────────────────────────────────────────────────────────────┐
│ SDK Layer (@sdk/*) │
│ • Defines interfaces for cross-cutting concerns │
│ • Analytics, Routing, HTTP, State, Experiments │
│ • Framework-agnostic contracts │
└─────────────────────────────────────────────────────────────────────────┘
│ │ │
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────────┐ ┌──────────────────────┐
│ Design System │ │ Blocks Layer │ │ Widgets Layer │
│ (@company-name │◄───│ (@blocks/*) │◄───│ (@widgets/*) │
│ /design-system)│ │ │ │ │
│ │ │ Business logic │ │ BFF contract impl │
│ Pure UI │ │ Domain components │ │ Page sections │
└─────────────────┘ └─────────────────────┘ └──────────────────────┘
┌──────────────────────────┐
│ Registries │
│ (@registries/*) │
│ │
│ Page-specific widget │
│ mappings │
└──────────────────────────┘
Primitives ← Blocks ← Widgets ← Registries ← Layout Engine ← Pages
↑ ↑
└─────────┴──── SDKs (injectable at all levels)
Source LayerCan ImportCannot Import
@sdk/*External libraries only@blocks, @widgets, @registries
@company-name/design-systemNothing from appEverything in app
@blocks/*Design system, @sdk/, sibling @blocks/@widgets/, @registries/
@widgets/*Design system, @sdk/, @blocks/@registries/, sibling @widgets/ (discouraged)
@registries/*@widgets/* (lazy imports only)@blocks/* directly
@layout/*Design system, @registries/*, @widgets/types@blocks/*

Purpose: Provide framework-agnostic abstractions for horizontal concerns.

Characteristics:

  • Define TypeScript interfaces (contracts)
  • Expose React hooks for consumption
  • Implementations provided at application level
  • No direct dependencies on application code

Examples:

  • @sdk/analytics - Event tracking, page views, user identification
  • @sdk/experiments - Feature flags, A/B testing
  • @sdk/router - Navigation, URL parameters
  • @sdk/http - API client abstraction
  • @sdk/state - Global state management

Purpose: Provide generic, reusable UI components.

Characteristics:

  • No business logic
  • No side effects
  • No domain-specific assumptions
  • Fully accessible and themeable
  • Lives in a separate repository/package

Examples:

  • Button, Input, Select, Checkbox
  • Card, Modal, Drawer, Tooltip
  • Typography, Grid, Stack, Divider
  • Icons, Animations, Transitions

Purpose: Compose Primitives with business logic to create reusable domain components.

Characteristics:

  • Business-aware but not page-specific
  • Reusable across multiple widgets
  • Can perform side effects via SDK hooks
  • Contains domain validation and formatting
  • Includes analytics and tracking

Examples:

  • ProductCard, ProductPrice, ProductRating
  • AddToCartButton, WishlistButton
  • UserAvatar, UserMenu
  • SearchInput, FilterChip

When to Create a Block:

  • Component is used in 2+ widgets
  • Component has business logic (not just styling)
  • Component needs analytics/tracking
  • Component interacts with global state

Purpose: Implement BFF widget contracts and compose the page.

Characteristics:

  • 1:1 mapping with BFF widget types
  • Receives payload from backend
  • Composes Blocks to render complete features
  • Handles widget-level concerns (pagination, error states)
  • Registered in page-specific registries

Examples:

  • HeroBannerWidget, ProductCarouselWidget
  • ProductGridWidget, FilterPanelWidget
  • RecommendationsWidget, RecentlyViewedWidget
  • ReviewsWidget, FAQWidget

Purpose: Map BFF widget types to component implementations per page type.

Characteristics:

  • Page-specific (different widgets on different pages)
  • Lazy-loaded components for code splitting
  • Configurable error boundaries and loading states
  • Simple Record<string, WidgetConfig> structure

SDKs are the key to framework agnosticism. They define what your components need, while the application shell provides how it’s implemented.

src/sdk/
├── index.ts # Re-exports all SDK hooks
├── core/
│ ├── sdk.types.ts # Combined SDK interface
│ ├── sdk.provider.tsx # Root provider
│ └── sdk.context.ts # Shared context utilities
├── analytics/
│ ├── analytics.types.ts # Interface definition
│ ├── analytics.provider.tsx # Context provider
│ ├── analytics.hooks.ts # useAnalytics() hook
│ └── index.ts # Public exports
├── experiments/
│ ├── experiments.types.ts
│ ├── experiments.provider.tsx
│ ├── experiments.hooks.ts
│ └── index.ts
├── router/
│ ├── router.types.ts
│ ├── router.provider.tsx
│ ├── router.hooks.ts
│ └── index.ts
├── http/
│ ├── http.types.ts
│ ├── http.provider.tsx
│ ├── http.hooks.ts
│ └── index.ts
├── state/
│ ├── state.types.ts
│ ├── state.provider.tsx
│ ├── state.hooks.ts
│ └── index.ts
└── testing/
├── test-sdk.provider.tsx # Test wrapper
├── create-mock-sdk.ts # Mock factory
└── index.ts
src/sdk/core/sdk.types.ts
export interface SdkServices {
analytics: AnalyticsSdk
experiments: ExperimentsSdk
router: RouterSdk
http: HttpSdk
state: StateSdk
}
src/sdk/analytics/analytics.types.ts
export interface AnalyticsSdk {
/**
* Track a custom event
*/
track(event: string, properties?: Record<string, unknown>): void
/**
* Track a page view
*/
trackPageView(page: string, properties?: Record<string, unknown>): void
/**
* Track component impression (visibility)
*/
trackImpression(componentId: string, properties?: Record<string, unknown>): void
/**
* Identify a user for analytics
*/
identify(userId: string, traits?: Record<string, unknown>): void
}
src/sdk/experiments/experiments.types.ts
export interface ExperimentsSdk {
/**
* Get the variant for an experiment
* @returns variant name or null if not enrolled
*/
getVariant(experimentId: string): string | null
/**
* Check if a feature flag is enabled
*/
isFeatureEnabled(featureFlag: string): boolean
/**
* Track that user was exposed to an experiment
*/
trackExposure(experimentId: string, variant: string): void
}
src/sdk/router/router.types.ts
export interface RouterSdk {
/**
* Navigate to a new URL (adds to history)
*/
push(path: string): void
/**
* Replace current URL (no history entry)
*/
replace(path: string): void
/**
* Go back in history
*/
back(): void
/**
* Prefetch a route for faster navigation
*/
prefetch(path: string): void
/**
* Current pathname
*/
pathname: string
/**
* Current query parameters
*/
query: Record<string, string | string[]>
}
src/sdk/http/http.types.ts
export interface HttpSdk {
get<T>(url: string, options?: RequestOptions): Promise<T>
post<T>(url: string, body: unknown, options?: RequestOptions): Promise<T>
put<T>(url: string, body: unknown, options?: RequestOptions): Promise<T>
delete<T>(url: string, options?: RequestOptions): Promise<T>
}
export interface RequestOptions {
headers?: Record<string, string>
signal?: AbortSignal
cache?: RequestCache
}
src/sdk/state/state.types.ts
export interface StateSdk {
/**
* Get current state for a key
*/
getState<T>(key: string): T | undefined
/**
* Set state for a key
*/
setState<T>(key: string, value: T): void
/**
* Subscribe to state changes
* @returns unsubscribe function
*/
subscribe<T>(key: string, callback: (value: T) => void): () => void
}
src/sdk/core/sdk.provider.tsx
import { createContext, useContext, type FC, type PropsWithChildren } from 'react';
import type { SdkServices } from './sdk.types';
const SdkContext = createContext<SdkServices | null>(null);
export const useSdk = (): SdkServices => {
const ctx = useContext(SdkContext);
if (!ctx) {
throw new Error('useSdk must be used within SdkProvider');
}
return ctx;
};
export interface SdkProviderProps {
services: SdkServices;
}
export const SdkProvider: FC<PropsWithChildren<SdkProviderProps>> = ({
children,
services,
}) => (
<SdkContext.Provider value={services}>
{children}
</SdkContext.Provider>
);
src/sdk/analytics/analytics.hooks.ts
import { useSdk } from "../core/sdk.provider"
import type { AnalyticsSdk } from "./analytics.types"
export const useAnalytics = (): AnalyticsSdk => {
const sdk = useSdk()
return sdk.analytics
}
src/sdk/experiments/experiments.hooks.ts
import { useEffect } from "react"
import { useSdk } from "../core/sdk.provider"
export const useExperiment = (experimentId: string): string | null => {
const { experiments } = useSdk()
const variant = experiments.getVariant(experimentId)
useEffect(() => {
if (variant !== null) {
experiments.trackExposure(experimentId, variant)
}
}, [experimentId, variant, experiments])
return variant
}
export const useFeatureFlag = (flagName: string): boolean => {
const { experiments } = useSdk()
return experiments.isFeatureEnabled(flagName)
}

The application shell provides concrete implementations:

// app/providers.tsx (framework-specific, outside src/)
'use client'; // Next.js specific
import { useMemo, type FC, type PropsWithChildren } from 'react';
import { useRouter, usePathname, useSearchParams } from 'next/navigation'; // Framework import OK here
import { SdkProvider, type SdkServices } from '@sdk/core';
/**
* Creates SDK service implementations using framework-specific APIs.
* This is the ONLY place where framework imports are allowed.
*/
const createSdkServices = (): SdkServices => ({
analytics: {
track: (event, props) => {
// Integrate with your analytics provider
// e.g., segment.track(event, props)
console.log('[Analytics] Track:', event, props);
},
trackPageView: (page, props) => {
console.log('[Analytics] Page View:', page, props);
},
trackImpression: (id, props) => {
console.log('[Analytics] Impression:', id, props);
},
identify: (userId, traits) => {
console.log('[Analytics] Identify:', userId, traits);
},
},
experiments: {
getVariant: (experimentId) => {
// Integrate with your experimentation platform
// e.g., return optimizely.getVariant(experimentId);
return null;
},
isFeatureEnabled: (flag) => {
// e.g., return launchDarkly.isEnabled(flag);
return false;
},
trackExposure: (experimentId, variant) => {
console.log('[Experiments] Exposure:', experimentId, variant);
},
},
router: {
push: (path) => window.location.href = path, // Simplified; use framework router
replace: (path) => window.location.replace(path),
back: () => window.history.back(),
prefetch: (path) => { /* Framework-specific prefetch */ },
pathname: typeof window !== 'undefined' ? window.location.pathname : '/',
query: {},
},
http: {
get: async (url, opts) => {
const res = await fetch(url, { ...opts, method: 'GET' });
return res.json();
},
post: async (url, body, opts) => {
const res = await fetch(url, {
...opts,
method: 'POST',
headers: { 'Content-Type': 'application/json', ...opts?.headers },
body: JSON.stringify(body),
});
return res.json();
},
put: async (url, body, opts) => {
const res = await fetch(url, {
...opts,
method: 'PUT',
headers: { 'Content-Type': 'application/json', ...opts?.headers },
body: JSON.stringify(body),
});
return res.json();
},
delete: async (url, opts) => {
const res = await fetch(url, { ...opts, method: 'DELETE' });
return res.json();
},
},
state: createStateAdapter(), // Implement based on your state management choice
});
export const AppProviders: FC<PropsWithChildren> = ({ children }) => {
const services = useMemo(() => createSdkServices(), []);
return (
<SdkProvider services={services}>
{children}
</SdkProvider>
);
};

src/
├── sdk/ # Internal SDKs
│ ├── index.ts # Public barrel: all SDK hooks
│ ├── core/
│ │ ├── sdk.types.ts
│ │ ├── sdk.provider.tsx
│ │ └── index.ts
│ ├── analytics/
│ │ ├── analytics.types.ts
│ │ ├── analytics.provider.tsx
│ │ ├── analytics.hooks.ts
│ │ └── index.ts
│ ├── experiments/
│ │ ├── experiments.types.ts
│ │ ├── experiments.provider.tsx
│ │ ├── experiments.hooks.ts
│ │ └── index.ts
│ ├── router/
│ │ ├── router.types.ts
│ │ ├── router.provider.tsx
│ │ ├── router.hooks.ts
│ │ └── index.ts
│ ├── http/
│ │ ├── http.types.ts
│ │ ├── http.provider.tsx
│ │ ├── http.hooks.ts
│ │ └── index.ts
│ ├── state/
│ │ ├── state.types.ts
│ │ ├── state.provider.tsx
│ │ ├── state.hooks.ts
│ │ └── index.ts
│ └── testing/
│ ├── test-sdk.provider.tsx
│ ├── create-mock-sdk.ts
│ └── index.ts
├── blocks/ # Business-aware building blocks
│ ├── index.ts # Public barrel
│ ├── blocks.types.ts # Shared Block types
│ │
│ ├── providers/ # Block-level providers (if needed)
│ │ ├── blocks.provider.tsx
│ │ └── index.ts
│ │
│ ├── testing/ # Block test utilities
│ │ ├── test-blocks.provider.tsx
│ │ ├── render-block.tsx
│ │ └── index.ts
│ │
│ ├── product-card/
│ │ ├── product-card.component.tsx # Container
│ │ ├── product-card.view.tsx # Pure render
│ │ ├── product-card.hooks.ts # Side effects
│ │ ├── product-card.types.ts # Types
│ │ ├── product-card.test.tsx # Tests
│ │ └── index.ts # Public API
│ │
│ ├── add-to-cart-button/
│ │ ├── add-to-cart-button.component.tsx
│ │ ├── add-to-cart-button.view.tsx
│ │ ├── add-to-cart-button.hooks.ts
│ │ ├── add-to-cart-button.types.ts
│ │ ├── add-to-cart-button.test.tsx
│ │ └── index.ts
│ │
│ └── [other-blocks]/
├── widgets/ # BFF-driven widgets
│ ├── index.ts # Public barrel
│ │
│ ├── types/ # Shared widget types
│ │ ├── widget.types.ts
│ │ ├── payload.types.ts
│ │ └── index.ts
│ │
│ ├── hero-banner/
│ │ ├── hero-banner.widget.tsx # Widget container
│ │ ├── hero-banner.view.tsx # Pure render
│ │ ├── hero-banner.hooks.ts # Widget logic
│ │ ├── hero-banner.types.ts # Payload types
│ │ ├── hero-banner.test.tsx
│ │ └── index.ts
│ │
│ ├── product-carousel/
│ │ ├── product-carousel.widget.tsx
│ │ ├── product-carousel.view.tsx
│ │ ├── product-carousel.hooks.ts
│ │ ├── product-carousel.types.ts
│ │ └── index.ts
│ │
│ └── [other-widgets]/
├── registries/ # Page-specific widget registries
│ ├── index.ts
│ ├── registry.types.ts # Registry type definitions
│ ├── home.registry.ts # Home page widgets
│ ├── pdp.registry.ts # Product detail page widgets
│ ├── plp.registry.ts # Product listing page widgets
│ ├── cart.registry.ts # Cart page widgets
│ └── checkout.registry.ts # Checkout page widgets
├── layout-engine/ # BFF layout composition
│ ├── index.ts
│ ├── layout-renderer.component.tsx
│ ├── widget-renderer.component.tsx
│ ├── layout.types.ts
│ └── layout.hooks.ts
└── shared/ # Non-UI utilities
├── types/
│ └── common.types.ts
└── utils/
├── format.utils.ts
└── validation.utils.ts
File TypePatternExample
Component (container){name}.component.tsxproduct-card.component.tsx
View (pure render){name}.view.tsxproduct-card.view.tsx
Widget container{name}.widget.tsxhero-banner.widget.tsx
Hooks{name}.hooks.tsproduct-card.hooks.ts
Types{name}.types.tsproduct-card.types.ts
Provider{name}.provider.tsxsdk.provider.tsx
Registry{name}.registry.tshome.registry.ts
Tests{name}.test.tsxproduct-card.test.tsx
Utilities{name}.utils.tsformat.utils.ts
Barrel exportindex.tsindex.ts

src/blocks/blocks.types.ts
import type { FC, PropsWithChildren } from "react"
/**
* A Block component - business-aware building block
*/
export type BlockComponent<TProps = object> = FC<TProps>
/**
* A Block View - pure presentational, no side effects
*/
export type BlockView<TProps = object> = FC<TProps>
/**
* Block with children
*/
export type BlockWithChildren<TProps = object> = FC<PropsWithChildren<TProps>>
/**
* Standard hook result for data-fetching blocks
*/
export interface BlockHookResult<TData, TActions = object> {
data: TData | null
isLoading: boolean
error: Error | null
actions: TActions
}
/**
* Props for analytics tracking (optional on all blocks)
*/
export interface TrackingProps {
/** Unique identifier for analytics */
trackingId?: string
/** Additional tracking data */
trackingData?: Record<string, unknown>
}
src/widgets/types/widget.types.ts
import type { ComponentType, ReactNode } from "react"
/**
* Base BFF widget payload structure
*/
export interface WidgetPayload<TData = unknown> {
/** Unique widget instance ID */
id: string
/** Widget type identifier (matches registry key) */
type: string
/** Widget-specific data from BFF */
data: TData
/** Optional pagination info */
pagination?: WidgetPagination
}
export interface WidgetPagination {
cursor: string | null
hasMore: boolean
pageSize: number
}
/**
* Widget component type
*/
export type WidgetComponent<TData = unknown> = ComponentType<{
payload: WidgetPayload<TData>
}>
/**
* Widget view - pure render layer
*/
export type WidgetView<TProps = object> = ComponentType<TProps>
/**
* Widget hook result with pagination support
*/
export interface WidgetHookResult<TData> {
data: TData | null
isLoading: boolean
error: Error | null
pagination: {
loadMore: () => Promise<void>
hasMore: boolean
isLoadingMore: boolean
} | null
}
src/registries/registry.types.ts
import type { ComponentType, ReactNode } from "react"
import type { WidgetPayload } from "@widgets/types"
/**
* Configuration for a registered widget
*/
export interface WidgetConfig {
/** The widget component to render */
component: ComponentType<{ payload: WidgetPayload }>
/** Optional custom error boundary */
errorBoundary?: ComponentType<{
children: ReactNode
fallback?: ReactNode
onError?: (error: Error) => void
}>
/** Optional suspense fallback (loading state) */
suspenseFallback?: ReactNode
/** Optional skeleton component for loading */
skeleton?: ComponentType
/** Whether to wrap in error boundary (default: true) */
withErrorBoundary?: boolean
/** Whether to wrap in suspense (default: true) */
withSuspense?: boolean
}
/**
* Widget registry - maps widget type IDs to configurations
*/
export type WidgetRegistry = Record<string, WidgetConfig>
src/blocks/add-to-cart-button/add-to-cart-button.types.ts
import type { TrackingProps, BlockHookResult } from "../blocks.types"
export interface AddToCartButtonProps extends TrackingProps {
sku: string
quantity?: number
variant?: "primary" | "secondary" | "ghost"
size?: "sm" | "md" | "lg"
disabled?: boolean
onSuccess?: () => void
onError?: (error: Error) => void
}
export interface AddToCartViewProps {
onAdd: () => void
isLoading: boolean
error: string | null
variant: "primary" | "secondary" | "ghost"
size: "sm" | "md" | "lg"
disabled: boolean
}
export interface AddToCartActions {
addToCart: () => Promise<void>
reset: () => void
}
export type UseAddToCartResult = BlockHookResult<{ cartId: string }, AddToCartActions>
src/blocks/add-to-cart-button/add-to-cart-button.hooks.ts
import { useState, useCallback } from "react"
import { useAnalytics, useHttpClient } from "@sdk"
import type { UseAddToCartResult } from "./add-to-cart-button.types"
export const useAddToCart = (
sku: string,
quantity: number = 1,
callbacks?: { onSuccess?: () => void; onError?: (error: Error) => void },
): UseAddToCartResult => {
const analytics = useAnalytics()
const http = useHttpClient()
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<Error | null>(null)
const [data, setData] = useState<{ cartId: string } | null>(null)
const addToCart = useCallback(async (): Promise<void> => {
setIsLoading(true)
setError(null)
try {
const response = await http.post<{ cartId: string }>("/api/cart/add", {
sku,
quantity,
})
setData(response)
analytics.track("add_to_cart", { sku, quantity, cartId: response.cartId })
callbacks?.onSuccess?.()
} catch (e) {
const error = e instanceof Error ? e : new Error("Failed to add to cart")
setError(error)
analytics.track("add_to_cart_error", { sku, error: error.message })
callbacks?.onError?.(error)
throw error
} finally {
setIsLoading(false)
}
}, [sku, quantity, http, analytics, callbacks])
const reset = useCallback((): void => {
setError(null)
setData(null)
}, [])
return {
data,
isLoading,
error,
actions: { addToCart, reset },
}
}
src/blocks/add-to-cart-button/add-to-cart-button.view.tsx
import type { FC } from 'react';
import { Button, Spinner, Text, Stack } from '@company-name/design-system';
import type { AddToCartViewProps } from './add-to-cart-button.types';
export const AddToCartButtonView: FC<AddToCartViewProps> = ({
onAdd,
isLoading,
error,
variant,
size,
disabled,
}) => (
<Stack gap="xs">
<Button
variant={variant}
size={size}
onClick={onAdd}
disabled={disabled || isLoading}
aria-busy={isLoading}
aria-describedby={error ? 'add-to-cart-error' : undefined}
>
{isLoading ? (
<>
<Spinner size="sm" aria-hidden />
<span>Adding...</span>
</>
) : (
'Add to Cart'
)}
</Button>
{error && (
<Text id="add-to-cart-error" color="error" size="sm" role="alert">
{error}
</Text>
)}
</Stack>
);
src/blocks/add-to-cart-button/add-to-cart-button.component.tsx
import type { FC } from 'react';
import { useAddToCart } from './add-to-cart-button.hooks';
import { AddToCartButtonView } from './add-to-cart-button.view';
import type { AddToCartButtonProps } from './add-to-cart-button.types';
export const AddToCartButton: FC<AddToCartButtonProps> = ({
sku,
quantity = 1,
variant = 'primary',
size = 'md',
disabled = false,
onSuccess,
onError,
}) => {
const { isLoading, error, actions } = useAddToCart(sku, quantity, {
onSuccess,
onError
});
return (
<AddToCartButtonView
onAdd={actions.addToCart}
isLoading={isLoading}
error={error?.message ?? null}
variant={variant}
size={size}
disabled={disabled}
/>
);
};
src/blocks/add-to-cart-button/index.ts
export { AddToCartButton } from "./add-to-cart-button.component"
export { AddToCartButtonView } from "./add-to-cart-button.view"
export { useAddToCart } from "./add-to-cart-button.hooks"
export type { AddToCartButtonProps, AddToCartViewProps } from "./add-to-cart-button.types"
src/widgets/product-carousel/product-carousel.types.ts
import type { WidgetPayload, WidgetHookResult } from "../types"
export interface ProductCarouselData {
title: string
subtitle?: string
products: ProductItem[]
}
export interface ProductItem {
id: string
sku: string
name: string
price: number
originalPrice?: number
imageUrl: string
rating?: number
reviewCount?: number
}
export type ProductCarouselPayload = WidgetPayload<ProductCarouselData>
export interface ProductCarouselViewProps {
title: string
subtitle?: string
products: ProductItem[]
onLoadMore?: () => void
hasMore: boolean
isLoadingMore: boolean
}
export type UseProductCarouselResult = WidgetHookResult<ProductCarouselData>
src/widgets/product-carousel/product-carousel.hooks.ts
import { useState, useCallback, useEffect } from "react"
import { useAnalytics, useHttpClient } from "@sdk"
import type { ProductCarouselPayload, UseProductCarouselResult } from "./product-carousel.types"
export const useProductCarousel = (payload: ProductCarouselPayload): UseProductCarouselResult => {
const analytics = useAnalytics()
const http = useHttpClient()
const [data, setData] = useState(payload.data)
const [isLoading, setIsLoading] = useState(false)
const [isLoadingMore, setIsLoadingMore] = useState(false)
const [error, setError] = useState<Error | null>(null)
const [cursor, setCursor] = useState(payload.pagination?.cursor ?? null)
const [hasMore, setHasMore] = useState(payload.pagination?.hasMore ?? false)
// Track impression when widget becomes visible
useEffect(() => {
analytics.trackImpression(payload.id, {
widgetType: payload.type,
productCount: data.products.length,
})
}, [payload.id, payload.type, analytics, data.products.length])
const loadMore = useCallback(async (): Promise<void> => {
if (!hasMore || isLoadingMore) return
setIsLoadingMore(true)
try {
const response = await http.get<{
products: ProductItem[]
cursor: string | null
hasMore: boolean
}>(`/api/widgets/${payload.id}/paginate?cursor=${cursor}`)
setData((prev) => ({
...prev,
products: [...prev.products, ...response.products],
}))
setCursor(response.cursor)
setHasMore(response.hasMore)
analytics.track("widget_load_more", {
widgetId: payload.id,
itemsLoaded: response.products.length,
})
} catch (e) {
setError(e instanceof Error ? e : new Error("Failed to load more"))
} finally {
setIsLoadingMore(false)
}
}, [payload.id, cursor, hasMore, isLoadingMore, http, analytics])
return {
data,
isLoading,
error,
pagination: payload.pagination ? { loadMore, hasMore, isLoadingMore } : null,
}
}
src/widgets/product-carousel/product-carousel.view.tsx
import type { FC } from 'react';
import { Section, Carousel, Button, Skeleton } from '@company-name/design-system';
import { ProductCard } from '@blocks/product-card';
import type { ProductCarouselViewProps } from './product-carousel.types';
export const ProductCarouselView: FC<ProductCarouselViewProps> = ({
title,
subtitle,
products,
onLoadMore,
hasMore,
isLoadingMore,
}) => (
<Section>
<Section.Header>
<Section.Title>{title}</Section.Title>
{subtitle && <Section.Subtitle>{subtitle}</Section.Subtitle>}
</Section.Header>
<Carousel itemsPerView={{ base: 2, md: 3, lg: 4 }}>
{products.map((product) => (
<Carousel.Item key={product.id}>
<ProductCard
productId={product.id}
sku={product.sku}
name={product.name}
price={product.price}
originalPrice={product.originalPrice}
imageUrl={product.imageUrl}
rating={product.rating}
reviewCount={product.reviewCount}
/>
</Carousel.Item>
))}
{isLoadingMore && (
<Carousel.Item>
<Skeleton variant="product-card" />
</Carousel.Item>
)}
</Carousel>
{hasMore && onLoadMore && (
<Section.Footer>
<Button
variant="ghost"
onClick={onLoadMore}
loading={isLoadingMore}
>
Load More
</Button>
</Section.Footer>
)}
</Section>
);
src/widgets/product-carousel/product-carousel.widget.tsx
import type { FC } from 'react';
import { useProductCarousel } from './product-carousel.hooks';
import { ProductCarouselView } from './product-carousel.view';
import type { ProductCarouselPayload } from './product-carousel.types';
interface ProductCarouselWidgetProps {
payload: ProductCarouselPayload;
}
export const ProductCarouselWidget: FC<ProductCarouselWidgetProps> = ({ payload }) => {
const { data, error, pagination } = useProductCarousel(payload);
if (error) {
// Let error boundary handle this
throw error;
}
if (!data) {
return null;
}
return (
<ProductCarouselView
title={data.title}
subtitle={data.subtitle}
products={data.products}
onLoadMore={pagination?.loadMore}
hasMore={pagination?.hasMore ?? false}
isLoadingMore={pagination?.isLoadingMore ?? false}
/>
);
};
src/registries/home.registry.ts
import { lazy } from "react"
import type { WidgetRegistry } from "./registry.types"
export const homeRegistry: WidgetRegistry = {
HERO_BANNER: {
component: lazy(() => import("@widgets/hero-banner").then((m) => ({ default: m.HeroBannerWidget }))),
withErrorBoundary: true,
withSuspense: true,
},
PRODUCT_CAROUSEL: {
component: lazy(() => import("@widgets/product-carousel").then((m) => ({ default: m.ProductCarouselWidget }))),
withErrorBoundary: true,
withSuspense: true,
},
CATEGORY_GRID: {
component: lazy(() => import("@widgets/category-grid").then((m) => ({ default: m.CategoryGridWidget }))),
},
PROMOTIONAL_BANNER: {
component: lazy(() => import("@widgets/promotional-banner").then((m) => ({ default: m.PromotionalBannerWidget }))),
},
NEWSLETTER_SIGNUP: {
component: lazy(() => import("@widgets/newsletter-signup").then((m) => ({ default: m.NewsletterSignupWidget }))),
withErrorBoundary: false, // Non-critical widget
},
}
src/registries/index.ts
import type { WidgetRegistry } from "./registry.types"
export { homeRegistry } from "./home.registry"
export { pdpRegistry } from "./pdp.registry"
export { plpRegistry } from "./plp.registry"
export { cartRegistry } from "./cart.registry"
export { checkoutRegistry } from "./checkout.registry"
export type { WidgetRegistry, WidgetConfig } from "./registry.types"
/**
* Get registry by page type identifier
*/
export const getRegistryByPageType = (pageType: string): WidgetRegistry => {
const registries: Record<string, () => Promise<{ default: WidgetRegistry }>> = {
home: () => import("./home.registry").then((m) => ({ default: m.homeRegistry })),
pdp: () => import("./pdp.registry").then((m) => ({ default: m.pdpRegistry })),
plp: () => import("./plp.registry").then((m) => ({ default: m.plpRegistry })),
cart: () => import("./cart.registry").then((m) => ({ default: m.cartRegistry })),
checkout: () => import("./checkout.registry").then((m) => ({ default: m.checkoutRegistry })),
}
// For synchronous access, import directly
// For async/code-split access, use the loader above
const syncRegistries: Record<string, WidgetRegistry> = {}
return syncRegistries[pageType] ?? {}
}

eslint.config.js
import boundaries from "eslint-plugin-boundaries"
import tseslint from "typescript-eslint"
export default [
...tseslint.configs.strictTypeChecked,
// Boundary definitions
{
plugins: { boundaries },
settings: {
"boundaries/elements": [
{ type: "sdk", pattern: "src/sdk/*" },
{ type: "blocks", pattern: "src/blocks/*" },
{ type: "widgets", pattern: "src/widgets/*" },
{ type: "registries", pattern: "src/registries/*" },
{ type: "layout", pattern: "src/layout-engine/*" },
{ type: "shared", pattern: "src/shared/*" },
{ type: "primitives", pattern: "node_modules/@company-name/design-system/*" },
],
"boundaries/ignore": ["**/*.test.tsx", "**/*.test.ts", "**/*.spec.tsx", "**/*.spec.ts"],
},
rules: {
"boundaries/element-types": [
"error",
{
default: "disallow",
rules: [
// SDK: no internal dependencies
{ from: "sdk", allow: [] },
// Blocks: primitives, sdk, sibling blocks, shared
{ from: "blocks", allow: ["primitives", "sdk", "blocks", "shared"] },
// Widgets: primitives, sdk, blocks, shared
{ from: "widgets", allow: ["primitives", "sdk", "blocks", "shared"] },
// Registries: widgets only (lazy imports)
{ from: "registries", allow: ["widgets"] },
// Layout: primitives, registries, shared
{ from: "layout", allow: ["primitives", "registries", "shared"] },
// Shared: primitives only
{ from: "shared", allow: ["primitives"] },
],
},
],
},
},
// Enforce barrel exports (no deep imports)
{
rules: {
"no-restricted-imports": [
"error",
{
patterns: [
{
group: ["@blocks/*/*"],
message: "Import from @blocks/{name} only, not internal files",
},
{
group: ["@widgets/*/*", "!@widgets/types", "!@widgets/types/*"],
message: "Import from @widgets/{name} only, not internal files",
},
{
group: ["@sdk/*/*"],
message: "Import from @sdk or @sdk/{name} only, not internal files",
},
],
},
],
},
},
// Block framework imports in components
{
files: ["src/blocks/**/*", "src/widgets/**/*", "src/sdk/**/*"],
rules: {
"no-restricted-imports": [
"error",
{
patterns: [
{
group: ["next/*", "next"],
message: "Use @sdk abstractions instead of Next.js imports",
},
{
group: ["@remix-run/*"],
message: "Use @sdk abstractions instead of Remix imports",
},
{
group: ["react-router", "react-router-dom"],
message: "Use @sdk/router instead of react-router",
},
],
},
],
},
},
// Blocks cannot import widgets
{
files: ["src/blocks/**/*"],
rules: {
"no-restricted-imports": [
"error",
{
patterns: [
{ group: ["@widgets", "@widgets/*"], message: "Blocks cannot import widgets" },
{ group: ["@registries", "@registries/*"], message: "Blocks cannot import registries" },
{ group: ["@layout", "@layout/*"], message: "Blocks cannot import layout-engine" },
],
},
],
},
},
// Widget-to-widget imports are discouraged
{
files: ["src/widgets/**/*"],
rules: {
"no-restricted-imports": [
"warn",
{
patterns: [
{
group: ["@widgets/*", "!@widgets/types", "!@widgets/types/*"],
message: "Widget-to-widget imports are discouraged. Extract shared logic to @blocks.",
},
],
},
],
},
},
// Strict TypeScript for SDK, Blocks, and Widgets
{
files: [
"src/sdk/**/*.ts",
"src/sdk/**/*.tsx",
"src/blocks/**/*.ts",
"src/blocks/**/*.tsx",
"src/widgets/**/*.ts",
"src/widgets/**/*.tsx",
],
languageOptions: {
parserOptions: {
project: "./tsconfig.json",
},
},
rules: {
"@typescript-eslint/explicit-function-return-type": "error",
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/strict-boolean-expressions": "error",
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/no-unsafe-assignment": "error",
"@typescript-eslint/no-unsafe-member-access": "error",
"@typescript-eslint/no-unsafe-call": "error",
"@typescript-eslint/no-unsafe-return": "error",
"@typescript-eslint/prefer-nullish-coalescing": "error",
"@typescript-eslint/prefer-optional-chain": "error",
"@typescript-eslint/no-unnecessary-condition": "error",
},
},
]

src/sdk/testing/create-mock-sdk.ts
import { vi } from "vitest"
import type { SdkServices } from "../core/sdk.types"
type DeepPartial<T> = {
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P]
}
export const createMockSdk = (overrides: DeepPartial<SdkServices> = {}): SdkServices => ({
analytics: {
track: vi.fn(),
trackPageView: vi.fn(),
trackImpression: vi.fn(),
identify: vi.fn(),
...overrides.analytics,
},
experiments: {
getVariant: vi.fn().mockReturnValue(null),
isFeatureEnabled: vi.fn().mockReturnValue(false),
trackExposure: vi.fn(),
...overrides.experiments,
},
router: {
push: vi.fn(),
replace: vi.fn(),
back: vi.fn(),
prefetch: vi.fn(),
pathname: "/",
query: {},
...overrides.router,
},
http: {
get: vi.fn().mockResolvedValue({}),
post: vi.fn().mockResolvedValue({}),
put: vi.fn().mockResolvedValue({}),
delete: vi.fn().mockResolvedValue({}),
...overrides.http,
},
state: {
getState: vi.fn().mockReturnValue(undefined),
setState: vi.fn(),
subscribe: vi.fn().mockReturnValue(() => {}),
...overrides.state,
},
})
src/sdk/testing/test-sdk.provider.tsx
import type { FC, PropsWithChildren } from 'react';
import { SdkProvider } from '../core/sdk.provider';
import { createMockSdk } from './create-mock-sdk';
import type { SdkServices } from '../core/sdk.types';
type DeepPartial<T> = {
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};
interface TestSdkProviderProps {
overrides?: DeepPartial<SdkServices>;
}
export const TestSdkProvider: FC<PropsWithChildren<TestSdkProviderProps>> = ({
children,
overrides = {},
}) => (
<SdkProvider services={createMockSdk(overrides)}>
{children}
</SdkProvider>
);
src/blocks/add-to-cart-button/add-to-cart-button.test.tsx
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { TestSdkProvider } from '@sdk/testing';
import { AddToCartButton } from './add-to-cart-button.component';
describe('AddToCartButton', () => {
const mockPost = vi.fn();
const mockTrack = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
const renderComponent = (props = {}) => {
return render(
<TestSdkProvider
overrides={{
http: { post: mockPost },
analytics: { track: mockTrack },
}}
>
<AddToCartButton sku="TEST-SKU" {...props} />
</TestSdkProvider>
);
};
it('adds item to cart on click', async () => {
mockPost.mockResolvedValueOnce({ cartId: 'cart-123' });
renderComponent();
fireEvent.click(screen.getByRole('button', { name: /add to cart/i }));
await waitFor(() => {
expect(mockPost).toHaveBeenCalledWith('/api/cart/add', {
sku: 'TEST-SKU',
quantity: 1,
});
});
});
it('tracks analytics on successful add', async () => {
mockPost.mockResolvedValueOnce({ cartId: 'cart-123' });
renderComponent({ quantity: 2 });
fireEvent.click(screen.getByRole('button'));
await waitFor(() => {
expect(mockTrack).toHaveBeenCalledWith('add_to_cart', {
sku: 'TEST-SKU',
quantity: 2,
cartId: 'cart-123',
});
});
});
it('displays error on failure', async () => {
mockPost.mockRejectedValueOnce(new Error('Network error'));
renderComponent();
fireEvent.click(screen.getByRole('button'));
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent(/network error/i);
});
});
it('disables button while loading', async () => {
mockPost.mockImplementation(() => new Promise(() => {})); // Never resolves
renderComponent();
fireEvent.click(screen.getByRole('button'));
await waitFor(() => {
expect(screen.getByRole('button')).toBeDisabled();
expect(screen.getByRole('button')).toHaveAttribute('aria-busy', 'true');
});
});
it('calls onSuccess callback', async () => {
mockPost.mockResolvedValueOnce({ cartId: 'cart-123' });
const onSuccess = vi.fn();
renderComponent({ onSuccess });
fireEvent.click(screen.getByRole('button'));
await waitFor(() => {
expect(onSuccess).toHaveBeenCalled();
});
});
});

tsconfig.json
{
"compilerOptions": {
// Strict mode (required)
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"strictPropertyInitialization": true,
"noImplicitThis": true,
"alwaysStrict": true,
// Additional checks
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noPropertyAccessFromIndexSignature": true,
// Path aliases
"baseUrl": ".",
"paths": {
"@company-name/design-system": ["node_modules/@company-name/design-system"],
"@company-name/design-system/*": ["node_modules/@company-name/design-system/*"],
"@sdk": ["src/sdk"],
"@sdk/*": ["src/sdk/*"],
"@blocks": ["src/blocks"],
"@blocks/*": ["src/blocks/*"],
"@widgets": ["src/widgets"],
"@widgets/*": ["src/widgets/*"],
"@registries": ["src/registries"],
"@registries/*": ["src/registries/*"],
"@layout": ["src/layout-engine"],
"@layout/*": ["src/layout-engine/*"],
"@shared": ["src/shared"],
"@shared/*": ["src/shared/*"],
},
// Module resolution
"target": "ES2020",
"lib": ["DOM", "DOM.Iterable", "ES2020"],
"module": "ESNext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"allowJs": false,
// React
"jsx": "react-jsx",
// Interop
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
// Output
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"skipLibCheck": true,
},
"include": ["src/**/*"],
"exclude": ["node_modules", "**/*.test.ts", "**/*.test.tsx"],
}
// package.json (scripts section)
{
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"typecheck": "tsc --noEmit",
"typecheck:watch": "tsc --noEmit --watch",
"lint": "eslint src/",
"lint:fix": "eslint src/ --fix",
"lint:strict": "eslint src/sdk src/blocks src/widgets --max-warnings 0",
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage",
"test:ci": "vitest --run --coverage",
"validate": "npm run typecheck && npm run lint:strict && npm run test:ci",
"prepare": "husky install",
},
}

  1. Set up SDK layer

    • Create src/sdk/ folder structure
    • Define all SDK interfaces
    • Implement mock SDK for testing
    • Create TestSdkProvider
  2. Configure tooling

    • Update tsconfig.json with path aliases
    • Configure ESLint with boundary rules
    • Add pre-commit hooks for validation
  3. Create application providers

    • Implement framework-specific SDK services
    • Wrap application with SdkProvider
  1. Identify block candidates

    • Audit existing components for reusability
    • List components used in 2+ places
    • Prioritize by usage frequency
  2. Migrate first blocks

    • Create src/blocks/ structure
    • Migrate 2-3 high-value components
    • Add comprehensive tests
    • Document patterns for team
  3. Replace framework dependencies

    • Update components to use SDK hooks
    • Remove direct next/ imports
    • Verify tests pass with mocked SDK
  1. Set up registries

    • Create src/registries/ structure
    • Define WidgetConfig type
    • Create page-specific registries
  2. Migrate widgets

    • Move BFF-connected components to src/widgets/
    • Ensure widgets compose Blocks
    • Register in appropriate page registries
  3. Update layout engine

    • Integrate registries with layout renderer
    • Add error boundaries and suspense
  1. Validate boundaries

    • Run lint:strict with zero warnings
    • Verify no cross-boundary imports
    • Audit for framework leakage
  2. Documentation

    • Update team documentation
    • Create component contribution guide
    • Record architecture decision records (ADRs)
  3. Team enablement

    • Conduct architecture walkthrough
    • Pair on first new component
    • Establish code review checklist

AspectConvention
Design SystemImport from @company-name/design-system
RoutingUse @sdk/router hooks
AnalyticsUse @sdk/analytics hooks
HTTP CallsUse @sdk/http hooks
Feature FlagsUse @sdk/experiments hooks
StateUse @sdk/state hooks
File Namingkebab-case with qualifiers (.component.tsx, .hooks.ts)
ExportsBarrel files (index.ts) only
TestingWrap with TestSdkProvider
TypeScriptStrict mode, no any
LayerPurposeFramework Dependency
PrimitivesGeneric UINone
SDKsCross-cutting concernsInterfaces only
BlocksBusiness componentsNone (uses SDKs)
WidgetsBFF integrationNone (uses SDKs)
RegistriesWidget mappingNone
  • Portability: Migrate between frameworks without rewriting components
  • Testability: Test components in isolation with mocked dependencies
  • Maintainability: Clear boundaries prevent spaghetti dependencies
  • Scalability: Teams can work independently on different layers
  • Consistency: Enforced patterns through tooling, not just documentation

Tags

Read more

  • Previous

    CSP-Sentinel Technical Design Document

    Design Documents 6 min read

    CSP-Sentinel is a centralized, high-throughput system designed to collect, process, and analyze Content Security Policy (CSP) violation reports from web browsers. As our web properties serve tens of thousands of requests per second, the system must handle significant burst traffic (baseline 50k RPS, scaling to 100k+ RPS) while maintaining near-zero impact on client browsers.The system will leverage a modern, forward-looking stack (Java 25, Spring Boot 4, Kafka, Snowflake) to ensure long-term support and performance optimization. It features an asynchronous, decoupled architecture to guarantee reliability and scalability.