Skip to Content
QA Docs & Abangku 0.1.0 is released. Read more →
Product EngineerFrontend & UI Guidelines

Frontend & UI Guidelines

This section is allowed to be more stack-concrete than most of this document, UI stacks are harder to abstract meaningfully than backend architecture, so the guidance here names specific tools and patterns directly rather than leading with abstract criteria.

5.1 Component library strategy

shadcn/ui components (see 2.2) are copied into the codebase, not installed as a versioned dependency. That changes how they’re treated:

  • Untouched primitives stay untouched. A shadcn/ui primitive as copied in (a Button, a Dialog) is not modified in place. Treat it as vendored code you didn’t author, changing it directly makes future upstream fixes or copy-ins harder to reconcile.
  • Customization happens in a wrapping component, not the primitive itself. If a primitive needs project-specific behavior or styling beyond what its existing props allow, wrap it in a new component that composes the primitive, rather than editing the vendored file.
  • Customized components live separately from the vendored primitives (e.g. vendored primitives in one directory, project-specific compositions in another), so it’s visually obvious at a glance which files are “ours” and which are copied-in vendor code.

Rule: a PR that modifies a file under the vendored shadcn/ui directory is a review flag unless it’s a deliberate, documented upstream sync. If a primitive doesn’t do what’s needed, wrap it, don’t edit it.

Default theme: violet. shadcn/ui’s violet color theme (see 2.2) is the default for every new project, applied at scaffold time rather than left at shadcn/ui’s generic default or re-picked per project. A single default theme is what keeps the product visually coherent across features built by different engineers on different teams, the same reasoning as the one icon library in 5.12 and the one charting library in 2.2.

  • A project deviating from violet needs a stated design reason (a distinct product surface with its own brand requirement), not personal preference, and the deviation is a deliberate choice documented where the theme is configured, not a silent per-project drift.
  • Customizing the theme means adjusting shadcn/ui’s theme tokens (the CSS variables the violet theme sets), not overriding individual component colors with arbitrary values, per the design-tokens rule in 5.3 below.

5.2 Spacing, sizing, and typography

This document doesn’t invent a design system from scratch. For anything not stated explicitly below, defer to an established reference system, Apple’s Human Interface Guidelines or Google’s Material Design, rather than re-deriving spacing and sizing conventions from first principles.

  • An 8-point grid. All spacing, sizing, and component dimensions align to multiples of 8px, with 4px available for finer adjustments where 8px is too coarse (icon padding, border widths). This is the same convention Material Design uses, and it’s what keeps a layout feeling consistent without a designer eyeballing every gap.
  • A fixed type scale, no one-off font sizes. Define a small, fixed set of type sizes (a modular scale) and never introduce a font size outside it for a single use case. This is the same “no arbitrary values” principle as 5.3 below, applied specifically to typography.
  • Two approved typefaces, each with a fixed role. Plus Jakarta Sans (see 2.2) is the default for UI chrome and body text, navigation, buttons, form labels, paragraph copy, any text whose job is to be read quickly and functionally. Source Serif 4 is reserved for display headings, the large, top-level heading on a page or section where a more editorial, less utilitarian tone is appropriate. Don’t use the serif for UI chrome or the sans for a display heading, the two exist to create a deliberate contrast, not as interchangeable options.
  • Load fonts through the framework’s font optimization, self-hosted rather than fetched from a third-party CDN at request time, per 12.1. Do not introduce a third typeface without going through the tool-evaluation process in 2.5, the same as any other stack addition.
  • Readable line length and line height. Body text is capped at roughly 60-75 characters per line and uses a line-height around 1.5, both are standard typographic readability guidelines, not arbitrary preferences, and both degrade reading comprehension measurably when violated.
  • Touch targets meet a minimum size. Any tappable/clickable target is at least 44x44pt (Apple HIG) or 48x48dp (Material), whichever convention the current platform guidance points to. A target smaller than this is a usability defect on touch devices, not just a visual nitpick, this is Fitts’s Law in concrete, testable form: smaller and farther targets take longer and are more error-prone to acquire.

Rule: a spacing value, font size, or touch target that doesn’t fit the grid, scale, or minimum above needs a stated reason in the PR (matching an external constraint, a third-party embed), it is not a default way to work around the system.

5.3 Styling conventions

  • Design tokens over arbitrary values. Use Tailwind’s configured design tokens (the spacing scale, the color palette, see 2.2) rather than arbitrary values (mt-[13px], text-[#3a3a3a]). An arbitrary value is a sign the design system is missing a token, propose adding the token rather than hardcoding around the gap.
  • Class sorting is enforced by tooling, not manual ordering. Utility class order is normalized automatically (via the project’s linting setup, see 4.3) so class order is never a point of review discussion or inconsistency between files.
  • Conditional classes go through cn() (the clsx + tailwind-merge pattern), never string concatenation or template literals for conditional class logic. cn() correctly resolves Tailwind class conflicts (e.g. a later p-4 overriding an earlier p-2), which naive string concatenation does not.

Rule: an arbitrary Tailwind value or a hand-built conditional class string is a code review flag, not a style nitpick, both are signs of either a missing design token or a missing use of cn().

5.4 Component composition

  • Presentational vs. container split. A presentational component receives data and callbacks via props and has no knowledge of where that data comes from. A container component owns data-fetching and state, and passes the results down to presentational components. This mirrors the layer separation in 3.2: a presentational component is pure UI, a container is where the application layer meets it.
  • Compound components for complex, multi-part UI. For UI with multiple related, interdependent parts (tabs, modals, multi-step forms), use the compound component pattern (e.g. Tabs, Tabs.List, Tabs.Panel) rather than one large component taking many boolean props to control internal structure. This is how shadcn/ui’s own primitives are structured, follow the same pattern for custom composite components.

Rule: a component whose prop list is mostly booleans toggling which internal pieces render is a candidate for the compound component pattern instead. If you’re passing showFooter, showHeader, and showSidebar as separate booleans, that’s a sign of a missing compound-component decomposition.

5.5 Rendering and data fetching

  • Rendering strategy is chosen per route, deliberately, not defaulted blindly. Next.js supports several rendering modes; pick based on the actual requirement:
    • Static (SSG) for content that’s the same for every visitor and changes rarely (marketing pages, this documentation site itself).
    • Incremental static regeneration (ISR) for content that’s mostly static but needs to refresh periodically without a full rebuild (a product catalog, a dashboard summary that tolerates a short staleness window, see 9.2).
    • Server-side rendering (SSR) for content that must be correct and current on every request and can’t tolerate any staleness (a signed-in user’s account state, anything permission-gated).
    • Client-only rendering only for content that’s inherently per-client and has no meaningful server-rendered version (a real-time widget, something that only makes sense after user interaction).
  • "use client" marks the boundary, not the whole tree. Push the client boundary down to the smallest leaf component that actually needs interactivity or browser-only APIs, don’t mark a whole page or layout client-side because one small piece of it needs useState. Every component under a "use client" boundary ships to the browser; this is the authoring discipline that keeps the bundle budget in 12.2 enforceable in practice, not just a monitoring problem caught after the fact.
  • Data fetching location matches the rendering strategy. A server component fetches data directly (including through tRPC, see 2.2) without needing a client-side loading state for that initial fetch. A client component uses TanStack Query for anything fetched or mutated after the initial render, per the server-cache state boundary in 9.1. Don’t fetch the same data both ways for the same view, pick the one that matches where the component actually lives.

Rule: a "use client" directive at the top of a page or layout file, rather than on the specific leaf component that needs it, is a review flag. Move the boundary down until only the components that truly need it are client components.

5.6 State management discipline

  • Colocate state with where it’s used. State lives in the component (or the nearest common ancestor of the components) that actually needs it, not lifted to a shared store or a top-level provider “in case it’s needed elsewhere later” (see 8.4 YAGNI). Promote state upward only when a second, real consumer needs it, the same promotion criteria as 3.4 applied to state instead of code.
  • Error boundaries are placed at a feature or section granularity, per the standardized convention in 10.2: wrap the specific section that can fail (a widget, a data-dependent panel), not only once at the root of the application. A single broken widget should not take down an entire page.
  • Empty states are a distinct, designed UI state, separate from loading, error, and not-found (see 10.2). A list with zero results because there’s genuinely no data yet (a new account, a filter with no matches) is not an error and not a loading state, it needs its own designed state, not a blank container or a misleading spinner that never resolves into content.

Rule: a view that can return zero results is not done until its empty state has been designed and implemented, the same way a view that can fail isn’t done without its error state (see the PR checklist in 4.2).

5.7 Third-party scripts, environment variables, flags, and copy

  • Third-party scripts load deferred or lazy, not blocking the main thread by default. Use the framework’s script-loading strategy (next/script with an appropriate loading strategy) for analytics, chat widgets, and embeds, so a third-party script never becomes the reason the page feels slow to the user, even when it’s slow itself.
  • Never destructure process.env ad hoc inside a component. Read environment variables through a single, validated config module (see 6.10 for the boot-time validation pattern this mirrors on the frontend), not scattered process.env.X references through component code. This is what prevents the common mistake of a server-only variable ending up referenced from client code, or a typo in a variable name failing silently instead of at build time.
  • Feature flags are read through one hook or utility, not scattered if (flag) checks copy-pasted through JSX. See 8.9 for flag tiers and ownership; this is the frontend-specific consumption pattern that keeps flag checks findable and removable together when the flag is retired.
  • User-facing copy lives in one place per feature, not hardcoded inline at every call site. The product is English-only today with no i18n infrastructure in place; centralizing copy regardless keeps wording consistent across a feature now, and avoids a scattered reflow of strings if i18n is ever added later.

5.8 Browser support and frontend testing conventions

  • Support the current and prior major version of evergreen browsers (Chrome, Edge, Firefox, Safari), auto-updating browsers that the vast majority of users are already on. Don’t add a polyfill or a compatibility workaround for a browser version outside this window without a stated business reason.
  • Progressive enhancement where it’s cheap, not required everywhere. Core flows should degrade gracefully where doing so is low-cost (see the prefers-reduced-motion handling already used on this site’s own scroll and animation behavior), but this document does not require the whole product work with JavaScript disabled, that’s a different, larger commitment this stack isn’t built around.
  • Test user-visible behavior, not implementation detail. Per Testing Library’s DOM/behavior-level testing approach (see 2.2), a component test asserts what a user would see or do (text is visible, a button is clickable and does X), not internal state or which specific function was called.
  • Snapshot tests are not a default. A snapshot test that captures an entire component’s rendered output tends to be approved reflexively on change without being actually read, which defeats its purpose. Prefer explicit assertions about specific, meaningful output; use a snapshot only for output that’s large, stable, and where a targeted assertion would be impractical to write by hand.

Rule: a new component test that only asserts a snapshot matches, with no explicit assertion about user-visible behavior, doesn’t meaningfully test the component. Add at least one behavioral assertion alongside or instead of the snapshot.

5.9 Complex UI: the compound component pattern

Referenced from 5.4 and elsewhere in this document (see 8.2 on Interface Segregation): a compound component is a set of related components that share implicit state (usually via context) and compose together to form one piece of UI, where each subcomponent has one clear responsibility.

  • Use it for tabs, modals, accordions, multi-step forms, and any UI where the “shape” of the composition varies by usage site (not every modal needs a footer, not every tab set needs the same number of tabs).
  • Don’t reach for it for simple, single-purpose components. A button or a badge doesn’t need a compound structure; this pattern earns its complexity only for genuinely multi-part UI.

5.10 Forms

React Hook Form + Zod (see 2.2) is the one standard pattern for forms. No ad hoc controlled inputs (manually wiring useState and onChange per field) outside of this pattern.

  • The Zod schema used for form validation is the same schema used for the corresponding API request (see 9.3), not a separate, hand-written client-side validator that can drift from the server’s actual requirements.
  • @hookform/resolvers connects the Zod schema to React Hook Form, so validation logic is written once and drives both the form’s inline error state and the eventual request payload shape.

Rule: a form built with manually managed useState per field, instead of React Hook Form, is a review flag. If React Hook Form genuinely doesn’t fit a specific, unusual input pattern, that’s rare enough to justify explicitly in the PR, not a default to fall back to.

5.11 Responsive design

Mobile-first is the default: styles are written for the smallest viewport first, with larger-viewport styles added via Tailwind’s responsive modifiers, not the other way around.

  • Every new UI is checked at mobile viewport widths before it’s considered done, this is already part of the PR checklist in 4.2.
  • A layout that only works above a certain viewport width, with no defined behavior below it, is incomplete, not a lower-priority follow-up.

5.12 Icons

lucide-react (see 2.2) is the one icon library, matching shadcn/ui’s own default icon set so icons drawn from shadcn/ui primitives and icons used elsewhere in the product look consistent.

Rule: do not add a second icon library for a one-off icon lucide doesn’t have. Either find the closest equivalent already in the set, or add a single custom SVG component for that specific case, introducing a whole second icon library for one missing glyph fragments the visual language across the product.

5.13 Animation

Tailwind’s built-in transition utilities are the default for animation (hover states, simple enter/exit transitions, color and transform changes on interaction).

Reach for a dedicated animation library (motion, see 2.2) only past a stated complexity threshold: orchestrated multi-step sequences, gesture-driven interactions, or animations that need to coordinate across multiple components. Below that threshold, a dedicated library is unnecessary weight for what a Tailwind transition class already does.

Rule: if an animation can be expressed as a Tailwind transition class, it should be. Reaching for motion for a simple hover or fade is over-engineering the same way a design pattern applied to a one-off case is (see 8.3).

5.14 UX principles and interaction laws

These are established, named principles from general UX practice, not rules invented for this document. Citing them by name gives the team shared vocabulary for design review, “this violates Hick’s Law” is a more actionable review comment than “this feels cluttered.”

  • Fitts’s Law: the time to acquire a target depends on its size and distance. Primary actions are sized generously and placed within easy reach (see the touch-target minimums in 5.2); a critical action should never be the smallest or hardest-to-reach element on the screen.
  • Hick’s Law: more choices presented at once increases decision time. Keep navigation, menus, and in-context choices limited and grouped, don’t surface every possible option flat in one list when a grouped or progressive structure would reduce the decision in front of the user.
  • Jakob’s Law: users spend most of their time on other products, and expect this one to work the same way. Don’t reinvent conventional patterns (where the primary action sits in a form, how a hamburger menu behaves, what a destructive action looks like) without a concrete reason; novelty here is a cost, not a feature.
  • Miller’s Law: working memory holds roughly 5-9 items at once. Don’t present a single screen, form, or step with more information or choices than that; chunk complex flows into steps rather than one dense screen.
  • Von Restorff effect (isolation effect): an item that visually differs from its surroundings is remembered and noticed more. Use this deliberately: a primary action should look visually distinct from secondary ones (contrast, color, size), not just positioned differently, so it’s the obvious next step rather than one option among visually equal alternatives.

Rule: when a design decision in review feels wrong but is hard to articulate, check it against the laws above before inventing new language for the same underlying problem. If it doesn’t map to any of these and still feels wrong, that’s worth raising anyway, these five aren’t exhaustive.

5.15 Color system

Color is a semantic system, not a palette of visually pleasing values picked per component. shadcn/ui’s violet theme (see 5.1) already defines this system as a set of named tokens; use the token that matches the color’s role, not the one that happens to look right.

  • Reach for the token that names the role, not the one whose hex value looks closest to what you want: primary for the main brand/action color, destructive for anything that deletes or irreversibly changes data, muted for de-emphasized text and backgrounds, accent for a secondary highlight. Picking primary because it happens to render the right shade of violet, for a button that isn’t actually the primary action, breaks the system’s meaning even though it looks correct today.
  • Color communicates state, consistently. The same semantic color always means the same thing across the product: destructive red always means “this is a dangerous or irreversible action,” never used decoratively for something that isn’t. A user who’s learned that red means danger in one part of the product should be able to trust that everywhere else.
  • Never introduce a one-off hex value for a state color. A new “warning” or “info” state uses the theme’s existing token for that role; if the theme genuinely doesn’t have one, that’s a gap in the theme (propose adding the token, see 5.3 above), not a reason to hardcode a color at the call site.
  • Color is never the only signal for meaning. A destructive action is red and labeled “Delete,” an error state is red and has an icon and text, not color alone. This is both an accessibility requirement (color-blind users can’t rely on hue alone, see 5.18) and a general UI clarity principle: color reinforces meaning that’s already conveyed another way, it doesn’t carry meaning by itself.

Rule: a PR introducing a raw color value (bg-[#7c3aed], text-red-600 reached for instead of the destructive token) instead of a semantic theme token is a review flag, the same class of issue as an arbitrary spacing value in 5.3.

5.16 Motion and easing standards

This is the concrete timing and easing companion to 5.13’s guidance on when to animate at all; this subsection defines how, once you’ve decided an interaction should animate.

  • Duration matches the size and distance of what’s moving. A small, local change (a hover state, a checkbox toggle, an icon state change) is fast, roughly 100-150ms. A larger UI transition (a panel sliding in, a modal appearing) is slower, roughly 200-300ms. An animation longer than that reads as sluggish for anything except a deliberately slow, attention-directing transition; don’t reach for a long duration by default.
  • Easing communicates physicality, use it deliberately. An element entering the screen eases out (starts fast, decelerates into place, Tailwind’s ease-out), an element leaving eases in (starts slow, accelerates away, ease-in). A linear easing curve (constant speed) reads as mechanical and is reserved for continuous, ongoing motion (a loading spinner), not a discrete enter/exit transition.
  • One motion vocabulary, not one animation per component author’s taste. The same kind of transition (a modal appearing, a toast entering) uses the same duration and easing everywhere it occurs in the product, so motion feels like one coherent system rather than a different designer’s opinion per feature.
  • Respect prefers-reduced-motion. Every animation, not just the large ones, has a reduced-motion fallback (an instant state change or a substantially shortened, simplified transition), consistent with the handling already in place on this site’s own scroll and animation behavior (see 5.8).

Rule: a new transition that doesn’t match an existing duration/easing pairing already used for the same kind of interaction (an enter, an exit, a hover) needs a stated reason, it’s not a default way to make an interaction feel more novel.

5.17 Data density and information hierarchy

How much information a screen shows at once, and what draws the eye first, are deliberate design decisions, not a byproduct of whatever data happens to be available.

  • Match density to the task. A dashboard or data table meant for fast scanning across many records (an ops or admin view) can be denser, tighter row height, smaller type, more columns visible at once, than a focused, single-record detail view meant to be read carefully. Applying one density standard everywhere either wastes space on a scanning view or overwhelms a focused one. A data table built on TanStack Table (see 2.2) applies this at the row-height and column-visibility level, not just as a general layout principle.
  • One visual hierarchy per screen. A screen has one clear primary action or piece of information (see the Von Restorff effect in 5.14), and everything else is visually subordinate to it through size, weight, color, and position, not competing for attention at the same visual weight. If a reviewer can’t tell what a screen wants the user to look at or do first within a couple of seconds, the hierarchy isn’t clear yet.
  • Progressive disclosure over showing everything at once. Secondary detail, advanced options, and rarely-needed information are available on demand (an expandable row, a detail panel, a secondary screen), not flattened into the primary view by default. This is the concrete application of Hick’s and Miller’s Laws (5.14) to information density specifically: more visible information at once means slower scanning and comprehension, not more value delivered.
  • Whitespace is a hierarchy tool, not empty space to fill. Grouping related elements closer together and separating unrelated ones with more space (per the spacing grid in 5.2) communicates structure without needing a visible border or divider for every grouping.

Rule: a new dense view (a table, a dashboard) states, in the PR or design review, what the primary scanning task is and what density serves it, rather than defaulting to “show all available fields” and letting hierarchy emerge by accident.

5.18 Accessibility baseline

shadcn/ui primitives already carry correct ARIA behavior for their respective patterns (focus trapping in a dialog, correct roles on a tab set), but that’s a starting point, not a guarantee the finished feature is accessible. Don’t assume accessibility is automatically handled just because the underlying primitive is accessible.

  • Semantic HTML first. Use the element that matches the content’s actual meaning (button for actions, nav for navigation, headings in order) rather than a div with click handlers and ARIA attributes bolted on to simulate semantics that a native element would provide for free.
  • Keyboard navigation is required, not optional. Any interactive element must be reachable and operable via keyboard alone: tab order follows visual order, custom interactive components (not from shadcn/ui) implement the expected keyboard behavior for their pattern (e.g. arrow keys within a custom tab list).
  • Focus management on state changes. When a modal opens, focus moves into it; when it closes, focus returns to what triggered it. When content is dynamically inserted (a toast, an async result), focus or an ARIA live region announces it, sighted keyboard users and screen reader users both depend on this, not just screen reader users alone.
  • Contrast minimums are met, checked against the actual rendered color tokens (see 5.3 and 5.15), not assumed from a design mockup. A custom color combination that fails contrast is a bug, the same as a broken layout.

Rule: using a shadcn/ui primitive does not exempt a feature from accessibility review. The primitive gets you correct ARIA roles and keyboard behavior for its own pattern; the composition around it (focus management across a multi-step flow, meaningful labels, contrast on custom styling layered on top) is still the feature author’s responsibility.

5.19 Client-side error reporting

Catching an error (see 10.2) and reporting it are two different responsibilities; an error boundary that only prevents a blank screen, without surfacing the failure anywhere, hides the problem from the team as effectively as it hides it from the user.

  • Every error boundary reports to Sentry (see 2.2) when it catches a rendering failure, with enough context (the route, the component tree, relevant props where they don’t contain sensitive data) to reproduce the issue without needing the reporting user to describe what happened.
  • A caught promise rejection or failed mutation is reported the same way as a rendering failure, an error a user sees as a failed save or a stuck loading state is exactly as worth knowing about as one that crashes a component tree, and is otherwise invisible to the team unless a user happens to report it.
  • User-facing error copy and the reported error are not the same text. The user sees the normalized, safe message from 10.1; Sentry receives the actual underlying error and stack trace. Never simplify what’s reported to match what’s shown to the user, that defeats the point of reporting it.

Rule: a new error boundary or a new mutation’s failure path that doesn’t report to Sentry is incomplete, the same way a view with no error state is incomplete per the 4.2 checklist. A team that only learns about a bug when a user complains found out later than it needed to.

5.20 Core Web Vitals and render performance

Section 12 covers asset optimization and bundle size; this subsection covers the render-time performance concerns specific to the frontend that a bundle-size budget alone doesn’t catch.

  • Core Web Vitals are a stated target, not an aspiration. Largest Contentful Paint (LCP) under 2.5s, Interaction to Next Paint (INP) under 200ms, and Cumulative Layout Shift (CLS) under 0.1, measured on real user data, not just a local Lighthouse run, since lab conditions routinely look better than what real users on real networks and devices experience.
  • Layout shift is prevented at the markup level, not patched after the fact. Reserve space for images and embeds before they load (the framework’s Image component does this automatically, see 12.1), and avoid inserting content above existing content after initial render unless it’s a deliberate, user-triggered change.
  • Unnecessary re-renders are a performance concern, not just a correctness one. A component re-rendering on every parent update because of an unstable prop reference (a new object or function literal created inline on every render) is a common, avoidable cost. Reach for memoization (useMemo, useCallback, React.memo) when a component is measurably re-rendering more than its inputs actually changed, not preemptively on every component, unneeded memoization adds its own overhead and cognitive cost for no benefit.
  • Measure before optimizing. Use React’s profiler or the browser’s performance tools to confirm a re-render or a slow render is actually happening and actually costly, before restructuring a component for performance. Optimizing a render path that wasn’t actually slow is wasted complexity, the same anti-pattern named in 8.3 for applying a pattern where none was needed.

Rule: a PR that adds a memoization hook justifies it with what was actually measured (a profiler flame graph, a re-render count), the same way 12.2 requires a stated reason for a bundle-size regression. Memoization added “just in case” is the render-performance equivalent of a premature abstraction.

5.21 Optimistic UI

Whether a mutation updates the UI immediately (optimistically, before the server confirms) or waits for confirmation is a deliberate, per-mutation decision, not a default applied uniformly.

  • Optimistic update when the action is low-risk and easily reversible, and getting instant feedback meaningfully improves the experience: toggling a like, checking off a to-do item, reordering a list. The cost of a rare rollback (the server rejects the change) is low compared to the UX gain of not waiting on every interaction.
  • Wait for confirmation when the action is high-stakes, hard to reverse, or the server’s response carries information the UI actually needs (a generated ID, a computed value, a validation result that can’t be predicted client-side): a payment, a submission that triggers a workflow, anything matching the hard-failure criteria in 8.1.
  • An optimistic update has a defined rollback. If the mutation fails after the UI already reflects the optimistic state, the UI reverts to the previous state and surfaces the failure through the normal error conventions (10.2), it does not silently leave the UI showing a state the server never actually committed.
  • TanStack Query’s optimistic update pattern (see 2.2) is the mechanism, paired with its built-in rollback-on-error handling, not a hand-rolled local state workaround that duplicates what the query library already provides.

Rule: a mutation that updates the UI optimistically without a rollback path for the failure case is not done, the same class of gap as a missing error state in the 4.2 checklist, just specific to the optimistic case.

5.22 Routing and navigation

  • Use the framework’s link component for in-app navigation (Next.js’s Link), not a raw anchor tag or a programmatic navigation call, for anything that’s a genuine navigation to another route. The framework’s link component handles prefetching and client-side transitions that a raw anchor or an unnecessary router.push bypass.
  • Programmatic navigation is reserved for navigation that only makes sense as a result of an action: redirecting after a successful form submission, navigating away after a mutation completes. Don’t use programmatic navigation for what’s really a link, a link is more discoverable, keyboard-accessible, and consistent with the framework’s prefetching behavior by default.
  • Prefetching is left at the framework’s default (prefetch on viewport visibility) unless a specific route is expensive enough to justify disabling it (a rarely visited, data-heavy route where prefetching wastes bandwidth for most users who never click through). Don’t disable prefetching by default across the board, that trades a real, broad performance benefit for a narrow, usually unmeasured concern.
  • URL structure reflects the resource, not the UI state that happens to render it. A route’s path names what the user is looking at (/ projects/123/settings), not an implementation detail of which component renders it. This is what keeps a URL meaningful and stable independent of how the page happens to be built, and is the concrete routing application of the URL-state boundary in 9.1.

Rule: a raw <a> tag or an unnecessary router.push used for in-app navigation, where the framework’s link component would work, is a review flag, both a performance and an accessibility regression compared to the framework default.

5.23 Component documentation and Storybook

Storybook and Chromatic (see 2.2) exist for developing and reviewing components in isolation; not every component needs a story, scope this to where it actually earns its cost.

  • A shared, reusable component gets a story: anything in the design-system or shared-component layer (a promoted component per 3.4), a customized wrapper around a shadcn/ui primitive (see 5.1), or any component with more than one meaningfully distinct visual state (a button’s variants, a form field’s error/disabled/default states).
  • A one-off, feature-specific component does not need a story by default, a component with exactly one call site and no meaningfully distinct states adds review and maintenance overhead for a story that will only ever be viewed once, if ever.
  • Chromatic’s visual regression review runs against the components with stories, per the phase-2 visual regression scope in 13.5, so a component’s story coverage is also what determines its visual regression coverage once that phase is adopted.

Rule: a new shared component promoted to a package (3.4) is not complete without a Storybook story covering its meaningfully distinct states, the same way it isn’t complete without tests per the 4.2 checklist.