Menu

The Right Way to Build Frontends

After working across dozens of frontend projects over the years, one pattern has become painfully clear: many teams are still building on shaky foundations. Project setups that felt “good enough” in the early days often become brittle, inconsistent, or hard to maintain as the product grows. Eventually, they slow everything down.

The irony? Most of these problems are avoidable with a bit more thought upfront.

There’s a quote from Uncle Bob’s *Clean Architecture* that feels especially relevant:

The bigger lie that developers buy into is the notion that writing messy code makes them go fast in the short term, and just slows them down in the long term… The fact is that making messes is always slower than staying clean, no matter which time scale you are using.

Clean Code ≠ Overengineering

This isn’t about perfectionism. It’s about reducing friction—today and six months from now. A clean, consistent frontend architecture makes it easier to:

  • Onboard new developers
  • Ship features without regressions
  • Refactor without fear
  • Swap out tools when needed
  • Avoid reinventing the wheel

And when things are done right, as Uncle Bob puts it:

“You don’t need hordes of programmers to keep it working… When software is done right, it requires a fraction of the human resources to create and maintain. Changes are simple and rapid. Defects are few and far between.”*

So What Does “Doing It Right” Look Like?

It means investing early in the boring but critical bits:

  • A clear project structure
  • Reusable, documented UI components
  • Type safety from day one
  • A shared design system (even a small one)
  • Linting, formatting, and CI that work out of the box
  • A dev environment that doesn’t make you sigh every morning

USE TYPESVRIPT PROPERLY

We typically work with React, using either **Next.js** or **Vite** as the core framework. The same principles apply if you’re using Vue or other frameworks—but this guide will assume a React base.

A consistent project structure, sensible defaults, and well-integrated tools reduce friction for every developer on the team. That starts with linting, formatting, environment handling, and test infrastructure.

Testing

Testing helps us ship confidently. But not everything needs to be tested. We aim for pragmatic coverage—focusing on complex logic, reusable utilities, and mission-critical flows where bugs would hurt the most. These are the pieces that benefit most from tests and are often reused across components or projects.

Tests should be **co-located** with the components or modules they cover (e.g., Button.test.tsx). This makes it easier to keep tests updated as the code changes, and helps new contributors understand how a given piece is meant to behave.

Use data-testid attributes (or similar custom selectors) for referencing DOM elements in tests. Avoid relying on class names—especially if you’re using Tailwind—because styling classes often change, and breaking tests because of purely visual updates isn’t helpful.

💡 LLMs like GPT-4 can generate robust test scenarios for logic-heavy code (e.g., utility functions). There’s no excuse for skipping test coverage on shared logic that underpins your UI or backend behavior.

Linting & Formatting

Linting catches common mistakes early and enforces shared conventions. Formatting keeps diffs clean and code readable.

* Use **ESLint** with recommended rulesets for React, TypeScript, and accessibility.

* Add **eslint-plugin-tailwindcss** if using Tailwind—it flags invalid class names automatically.

* Include **Prettier** to standardize formatting. Configure it to run on save (e.g., via your editor) or during commit.

* Consider **Biome** as a faster formatter/linter alternative if performance is a concern.

Set this up from day one. Retroactively adding linting to a large codebase usually means hundreds of inconsistencies to fix all at once.

Environment Variables

Environment variables should be **validated and type-checked** at build time—not just assumed to exist. Use tools like zod or env-var to assert their presence and structure.

Inconsistent or undocumented env usage is one of the most common sources of deployment bugs. Having process.env.SOME_API_KEY fail silently in production is never fun. Instead, fail early and loudly.

Additionally, secrets should dieally not be shared over Slackl. Vercel allows. However, once you need to start sharing those variables with other environments (e.g. Trigger, Github Actions), things get more complicated. Solutions like Doppler are recommended becuase they allow you to manage secrets in a secure, centralised way.

### 🚦 Continuous Integration

Automate the essentials:

* **Lint**: catches issues before they reach main

* **Type check**: ensures TypeScript correctness

* **Tests**: runs Vitest on PRs

Use **GitHub Actions** (or similar) to run these checks on every pull request. This ensures consistency, avoids regressions, and keeps the codebase healthy over time.

Package Management

We recommend **pnpm** for its speed and disk efficiency. It uses a content-addressable store and avoids node_modules bloat, making dependency management significantly faster and more reliable in monorepos or larger apps.

Accessibility

Integrate accessibility testing into your workflow early. Tools like **axe-core**, **eslint-plugin-jsx-a11y**, or **Playwright’s accessibility audit** can catch common issues automatically. Just as with performance or SEO, accessibility should be part of your definition of “done,” not an afterthought.

Storybook

Use **Storybook** to document and test UI components in isolation. This encourages reusable, modular design and lets non-developers (designers, PMs) interact with components without running the full app. It’s especially helpful in large teams or design-heavy projects.

Copy & internationalisation

Even if you’re not planning full multilingual support, extract all user-facing copy into a centralised place using something like next-intl. This improves clarity, enables future localisation, and keeps your UI consistent.

Functional Utilities

Use libraries like ts-pattern (pattern matching) and effect (typed effects and async handling) for managing complex logic, especially in forms, data-fetching, and conditional rendering. These tools make business logic easier to test, reason about, and refactor safely over time.

Dependency Injection

Favor dependency injection over hard-coding imports or configuration directly into your logic. This makes your code more flexible, testable, and decoupled. For example, rather than importing an API module directly into a component or utility, pass it in as a dependency.

This approach helps isolate responsibilities and improves both testing and reusability—particularly in environments where implementations may vary (e.g., client vs. server, mock vs. live).

Group Logic with Classes (When It Makes Sense)

While functional programming is often favored in React and JavaScript projects, there are still places where classes shine—especially when grouping related state and behavior in a single, encapsulated object.

Use classes for:

* Complex domain logic (e.g., a PriceCalculator, Cart, or Subscription model)

* Entities with multiple responsibilities and invariants

* Shared logic that isn’t tied to React’s lifecycle or hooks

By bundling related behavior together, classes can improve clarity and reduce leakage of implementation details.

Comment with Purpose

Good comments don’t describe what the code is doing—they explain why it was written that way. Always write comments with future readers in mind.

Use comments to:

* Clarify non-obvious decisions or tradeoffs

* Link to related context (e.g., a GitHub issue or API spec)

* Add TODOs with a clear intention (and ideally a ticket)

*// We're using a fixed offset here to account for DST bugs in legacy systems.*

*// See: https://github.com/org/repo/issues/123*

Avoid redundant or obvious comments, and keep them short and focused.

Remove Dead Code

Commented-out or unused code doesn’t belong in your mainline branches. It adds noise, creates confusion, and rarely gets revisited. Instead, rely onm Git history for recovery

* Pull Requests for review context

* Feature flags or branches for work-in-progress features

### 🔧 Prefer Utility Libraries Over Reinventing the Wheel

Writing custom utility functions can be tempting, but it often leads to edge cases, inconsistency, and unnecessary maintenance. Instead, lean on established libraries that have been battle-tested by the community.

Some recommended choices:

* [remeda](https://remedajs.com): Type-safe functional utilities (map, filter, groupBy, etc.)

* [react-use](https://github.com/streamich/react-use): A comprehensive collection of ready-made hooks

* [date-fns](https://date-fns.org) or [luxon](https://moment.github.io/luxon/#/): For parsing, formatting, and manipulating dates without the bloat of Moment.js

* [zod](https://zod.dev): For schema validation and ensuring runtime type safety

* [ts-pattern](https://github.com/gvergnaud/ts-pattern): Type-safe pattern matching, useful for state transitions and discriminated unions

Clean code isn’t about perfection—it’s about clarity and intent. By following a few consistent patterns, we can move faster with confidence, reduce bugs, and make onboarding new developers far easier.

Would you like me to help fold this into a longer guide with previous sections, or continue with more best practices (e.g., naming, file structure, data fetching, etc.)?

🎨 Use CVA for Styling Variants

Use [class-variance-authority](https://cva.style) (CVA) to handle variant styles without cluttering your components with conditional logic. This pattern makes your styles more declarative and easier to extend over time—especially useful for design systems or shared UI libraries.

typescript
Expand / Collapse
Copy
1const button = cva("px-4 py-2 rounded", {
2 variants: {
3 intent: {
4 primary: "bg-blue-500 text-white",
5 secondary: "bg-gray-100 text-black",
6 },
7 size: {
8 sm: "text-sm",
9 md: "text-base",
10 },
11 },
12});

You can also abstract styles into re-usable atoms that can be used in other components to help create consistency. For example, suppose you want to create a button that look like an input (a use case for this might be a search bar that triggers a search modal or similar, but presents as an input). You can create

Setup theming

Separate component logic from presentation

In the case of React, this can be achieved by creating dedicating context providers that encapsulate the logic. Here's an example of a search component:

Use useMemo, useCallback, and React.memo where:

* Props don’t change often

* Re-renders are expensive (e.g., large tables, charts, image galleries)

Don’t overdo it—premature memoization adds complexity. But in the right places, it can shave off significant CPU time.

typescript
Expand / Collapse
Copy
1const [SearchContext, useSearchContext] = createContext<SearchContextProps>();
2
3export const SearchProvider = ({ children }) => {
4 const [query, setQuery] = useState("");
5 return (
6 <SearchContext.Provider value={{ query, setQuery }}>
7 {children}
8 </SearchContext.Provider>
9 );
10};

This allows your components to stay focused on rendering, while business logic is centralised and easier to test. Among other things, it makes it easier to refactor the presentation without worrying about breaking business logic.

Prefer Collocation Over Global Grouping

A common anti-pattern is grouping all hooks, types, and utilities into separate global folders. This can make the project feel more organized—but in practice, it hinders understanding and leads to forgotten or misused code. [Kent C. Dodds has a great article on this topic](https://kentcdodds.com/blog/how-to-write-a-react-component-library).

Collocation makes the intent clearer: if a utility lives next to a component, it’s obvious whether it should (or shouldn’t) be reused elsewhere. It also improves code hygiene—when a component is removed, its co-located helpers are more likely to be cleaned up too.

Instead:

* **Collocate files by module or feature**—keep hooks, utils, and types next to the components that use them.

* If a utility is truly global (e.g., formatDate), place it in a top-level utils folder. But if it’s only used by a single component or feature, keep it local.

### 🧩 Embrace Composability

Create a Box component

Design components to be small, focused, and composable. This makes them easier to test, reuse, and iterate on without introducing regressions.

Radix’s design pattern is a great model here—think in terms of primitives and wrappers. Avoid components that mix layout, logic, and behavior all in one file.

Also consider using a Box component that wraps [Radix’s Slot](https://www.radix-ui.com/docs/primitives/utilities/slot) utility. This allows you to forward props to child elements via asChild, simplifying how components can be composed:

<Box asChild className="block p-4">

<button>Click me</button>

</Box>

This pattern enables flexibility while preserving type safety and style encapsulation.

Use trpc

### 💤 Lazy Load Strategically

Split your app into meaningful chunks and load only what’s needed.

* Use **dynamic imports** in Next.js or Vite to load non-critical components:

const ChatWidget = dynamic(() => import("./ChatWidget"), { ssr: false });

* Avoid bundling rarely-used modals, feature flags, or dashboards in the main entry point.

* Use **React Suspense** with fallbacks to avoid janky transitions.

Interested in learning more? Email us.
Button -