Engineering

Our Frontend Stack: Bun, Monorepos, and Feature-Sliced Design

DR
David Ross Principal Engineer, Axiron
Published December 18, 2024
Read Time 11 min read

Frontend architecture decisions compound. A poor choice early on becomes technical debt that slows every feature for years. A good choice becomes invisible infrastructure that lets teams move fast without thinking about it.

At Axiron, we’ve iterated on our frontend stack across multiple products. This is what we’ve landed on—and why.

The Runtime: Why Bun

We migrated from Node.js to Bun eighteen months ago. The decision wasn’t about hype—it was about developer experience and CI performance.

Speed That Matters

Bun’s performance advantage isn’t theoretical. Our numbers:

OperationNode.js + npmBun
Fresh install47s8s
Cached install12s1.2s
Dev server start4.2s1.8s
Test suite (unit)34s11s

In CI, where we run hundreds of pipelines daily, the savings compound to hours per week. Locally, the difference between a 4-second and 2-second dev server start changes behavior—developers reload more freely, experiment more often.

Built-in Tooling

Bun ships with a test runner, bundler, and package manager. We no longer maintain separate Jest, webpack, and npm configurations. One tool, one config paradigm.

# Everything through one binary
bun install          # Package management
bun run dev          # Scripts
bun test             # Testing
bun build            # Bundling

Compatibility Reality

Bun isn’t 100% Node-compatible, but it’s close enough. We hit edge cases maybe twice per quarter, usually in obscure dependencies. The productivity gains far outweigh the occasional workaround.

The Structure: Monorepo Architecture

All our frontend code lives in a single repository. Not because monorepos are trendy—because they solve real coordination problems.

The Layout

axiron/
├── apps/
│   ├── vault-web/           # Vault web application
│   ├── vault-mobile/        # Vault React Native app
│   ├── lumina-web/          # Lumina web application
│   ├── internal-dashboard/  # Internal tools
│   └── marketing/           # Marketing sites
├── packages/
│   ├── ui-kit/              # Shared component library
│   ├── api-client/          # Generated API clients
│   ├── utils/               # Shared utilities
│   ├── config/              # Shared configs (ESLint, TS, etc.)
│   └── tokens/              # Design tokens
└── tooling/
    ├── generators/          # Code generators
    └── scripts/             # Build/deploy scripts

Why This Works

Atomic changes across packages. When we update the Button component in ui-kit, every app gets the change in the same PR. No version coordination. No “update the design system” tickets that sit for months.

Shared infrastructure. ESLint rules, TypeScript configs, and CI pipelines are defined once. New apps inherit everything automatically.

Refactoring confidence. IDE refactoring works across the entire codebase. Rename a type in api-client, and every usage updates.

Workspace Management

Bun’s workspaces handle dependency hoisting and cross-package linking:

// package.json (root)
{
  "workspaces": ["apps/*", "packages/*", "tooling/*"]
}
// apps/vault-web/package.json
{
  "dependencies": {
    "@axiron/ui-kit": "workspace:*",
    "@axiron/api-client": "workspace:*",
    "@axiron/utils": "workspace:*"
  }
}

The workspace:* protocol means apps always use the local version—no publishing, no version mismatches.

The Component System: UI Kit

Our UI kit is the visual foundation for every Axiron product. It’s not a from-scratch design system—it’s a carefully composed layer on top of proven primitives.

The Stack

┌─────────────────────────────────┐
│         Axiron UI Kit           │  ← Our components, our tokens
├─────────────────────────────────┤
│           shadcn/ui             │  ← Styled, accessible components
├─────────────────────────────────┤
│           Base UI               │  ← Unstyled primitives
├─────────────────────────────────┤
│         Radix Primitives        │  ← Accessibility foundation
└─────────────────────────────────┘

Why Not Build From Scratch?

Accessibility is hard. Really hard. Building a dropdown that works correctly with screen readers, keyboard navigation, focus management, and ARIA attributes takes weeks of specialized work. Radix and Base UI have already solved this.

We layer on top:

  1. Base UI / Radix provides unstyled, accessible primitives
  2. shadcn/ui provides sensible default styling and composition patterns
  3. Our UI Kit applies Axiron’s design tokens and customizations

Component Anatomy

Every component follows the same structure:

// packages/ui-kit/src/components/button/button.tsx
import { forwardRef } from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@axiron/utils";

const buttonVariants = cva(
  // Base styles
  "inline-flex items-center justify-center rounded-lg font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50",
  {
    variants: {
      variant: {
        primary: "bg-white text-zinc-950 hover:bg-zinc-200",
        secondary: "bg-zinc-800 text-white hover:bg-zinc-700",
        outline: "border border-zinc-700 text-zinc-300 hover:bg-zinc-800",
        ghost: "text-zinc-400 hover:text-white hover:bg-zinc-800",
        destructive: "bg-red-600 text-white hover:bg-red-700",
      },
      size: {
        sm: "h-8 px-3 text-xs",
        md: "h-10 px-4 text-sm",
        lg: "h-12 px-6 text-base",
      },
    },
    defaultVariants: {
      variant: "primary",
      size: "md",
    },
  },
);

export interface ButtonProps
  extends
    React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {
  asChild?: boolean;
}

export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
  ({ className, variant, size, asChild = false, ...props }, ref) => {
    const Comp = asChild ? Slot : "button";
    return (
      <Comp
        className={cn(buttonVariants({ variant, size, className }))}
        ref={ref}
        {...props}
      />
    );
  },
);

Design Tokens

Colors, spacing, typography—all defined as tokens that flow through the entire system:

// packages/tokens/src/colors.ts
export const colors = {
  zinc: {
    50: "#fafafa",
    // ...
    950: "#09090b",
  },
  brand: {
    primary: "#ffffff",
    accent: "#10b981", // emerald-500
  },
  semantic: {
    success: "#22c55e",
    warning: "#f59e0b",
    error: "#ef4444",
    info: "#3b82f6",
  },
} as const;

Tokens are consumed via Tailwind config, CSS custom properties, and direct imports—whatever the context requires.

The Architecture: Feature-Sliced Design

The hardest part of frontend architecture isn’t choosing tools—it’s organizing code in a way that scales with team size and codebase complexity.

We use Feature-Sliced Design (FSD), an architectural methodology that enforces clear boundaries and predictable dependencies.

The Layer Hierarchy

FSD organizes code into layers, each with specific responsibilities:

src/
├── app/           # Application initialization, providers, routing
├── pages/         # Page components, route-level code splitting
├── widgets/       # Large composite blocks (Header, Sidebar, etc.)
├── features/      # User interactions (AddToCart, AuthForm, etc.)
├── entities/      # Business entities (User, Product, Transaction)
├── shared/        # Reusable utilities, UI kit, API clients

The key rule: layers can only import from layers below them. A feature can import from entities and shared, but never from widgets or pages.

Slice Structure

Within each layer, code is organized into slices—vertical domains of functionality:

src/features/
├── auth/
│   ├── ui/                 # React components
│   │   ├── login-form.tsx
│   │   └── logout-button.tsx
│   ├── model/              # State, business logic
│   │   ├── auth.store.ts
│   │   └── auth.selectors.ts
│   ├── api/                # API calls
│   │   └── auth.api.ts
│   ├── lib/                # Utilities specific to this slice
│   │   └── token-storage.ts
│   └── index.ts            # Public API
├── transactions/
│   ├── ui/
│   ├── model/
│   ├── api/
│   └── index.ts

The Public API Pattern

Each slice exposes a public API through its index.ts. Internal implementation details stay private:

// src/features/auth/index.ts

// Public components
export { LoginForm } from "./ui/login-form";
export { LogoutButton } from "./ui/logout-button";

// Public hooks
export { useCurrentUser } from "./model/auth.selectors";
export { useAuth } from "./model/auth.store";

// Public types
export type { User, AuthState } from "./model/types";

// Internal modules are NOT exported
// ./lib/token-storage.ts stays private
// ./api/auth.api.ts stays private

Consumers import from the slice, never from internal paths:

// ✅ Correct
import { LoginForm, useCurrentUser } from "@/features/auth";

// ❌ Wrong - reaching into internals
import { LoginForm } from "@/features/auth/ui/login-form";

Why This Matters

Predictable dependencies. When you see an import from @/entities/user, you know exactly what layer it comes from and what it might contain.

Safe refactoring. Internal implementation changes don’t break consumers. As long as the public API stays stable, the internals can evolve freely.

Clear ownership. Each slice is a cohesive unit that one developer or team can own. Boundaries prevent accidental coupling.

Onboarding speed. New developers learn the pattern once and can navigate any part of the codebase. Structure is consistent everywhere.

Practical Example

Here’s how a page composes layers together:

// src/pages/dashboard/ui/dashboard-page.tsx
import { Header, Sidebar } from "@/widgets/layout";
import { TransactionList } from "@/features/transactions";
import { BalanceCard } from "@/features/account";
import { useCurrentUser } from "@/entities/user";
import { PageLayout } from "@/shared/ui";

export const DashboardPage = () => {
  const user = useCurrentUser();

  return (
    <PageLayout>
      <Header user={user} />
      <Sidebar />
      <main>
        <BalanceCard userId={user.id} />
        <TransactionList userId={user.id} />
      </main>
    </PageLayout>
  );
};

Each import comes from a specific layer. The page orchestrates, features handle interactions, entities provide data, shared provides primitives.

The Compound Effect

None of these choices exist in isolation. They reinforce each other:

  • Bun makes the monorepo fast enough to be practical
  • Monorepo enables sharing the UI kit across all apps
  • UI kit provides consistent primitives for FSD slices
  • FSD keeps the growing codebase navigable

Remove any piece, and the others become harder to maintain.

Getting Started

If you’re evaluating similar approaches, start small:

  1. Bun: Drop-in replace npm. Revert if something breaks.
  2. Monorepo: Start with two packages—your app and a shared utilities package.
  3. UI kit: Copy shadcn components you actually use. Don’t build what you don’t need.
  4. FSD: Introduce one slice. See how it feels before committing fully.

Architecture evolves. What matters is having principles that guide evolution in a consistent direction.


Have questions about our frontend stack? We’re always happy to talk shop. Reach out at [email protected].

DR
David Ross Principal Engineer, Axiron

Read next