Back to Blog
UI Engineering

Designing Modern Dashboards with Tailwind CSS and TypeScript

Patterns for building admin dashboards that stay maintainable at scale — typed data contracts, composable Tailwind primitives, semantic color, and a hard boundary between server state and UI state.

Mehran HatamiMehran Hatami6 min read
Tailwind CSSTypeScriptDashboardsDesign Systems

Dashboards fail in a predictable way: a few screens grow into dozens, state gets tangled between components, and nobody remembers which prop controls what. This isn't really a Tailwind problem or a TypeScript problem — it's an architecture problem that just happens to show up in the markup and the types, because that's where it's easiest to see.

What follows is the set of patterns I actually reach for to keep a dashboard maintainable well past the point where most of them start to rot: a typed data contract that comes before any component, a small set of composed primitives instead of copy-pasted cards, color used as a signal instead of decoration, and a hard boundary between server state and UI state.

Start with the data contract, not the component

Key Insight

A clear boundary between server state and UI state is the single biggest factor in whether a large dashboard stays maintainable.

Every dashboard screen renders something concrete — orders, licenses, inventory, revenue. Before writing a single div, I write the TypeScript type that describes that something, shared between the API layer and the component that renders it. Once that type exists, the component's job shrinks to "render this shape," which is a much easier problem than "figure out what data might show up here."

I validate at the boundary — usually with zod, right where data enters the app from an API route or a server action — and trust the inferred type everywhere past that point. That single validation call is worth more than defensive optional-chaining scattered through forty components.

A type scale that survives dense data

Dashboards pack more information per screen than almost any other UI category, so a type scale that works for a marketing page will fight you constantly. I keep it deliberately small and tie each step to a specific job, not a vibe:

  • 12px / text-xs — table-cell metadata: timestamps, IDs, secondary labels
  • 14px / text-sm — body copy, form labels, the default for most table cells
  • 16px / text-base — the primary data value in a table row, so it's scannable at a glance
  • 20px / text-xl — stat-tile numbers, used sparingly so they still draw the eye
  • 28px and up — page headings only, once per screen, never inside a card

The rule that matters more than the exact pixel values: once a size is assigned a job, nothing else gets to reuse it for a different job just because it happens to look right in one spot.

Composing primitives instead of copy-pasting cards

Tailwind CSS works best in this context when it's composed, not copy-pasted. I build a small set of primitives — card, stat tile, data-table row — once, with variants handled through simple props, so a dashboard with fifty screens still has one visual language instead of fifty slightly different ones. A stat tile ends up looking like this:

interface StatTileProps {
  label: string;
  value: string | number;
  tone?: "neutral" | "success" | "warning" | "danger";
}

export function StatTile({ label, value, tone = "neutral" }: StatTileProps) {
  return (
    <div className="glass-card rounded-card p-4">
      <p className="text-xs text-foreground/50">{label}</p>
      <p className={cn("mt-1 text-xl font-semibold", toneText[tone])}>
        {value}
      </p>
    </div>
  );
}

Every screen that needs a number in a box reuses this one component. When the design changes, it changes in one place — not in the forty screens that copy-pasted the original markup before anyone noticed the pattern.

Color as a signal, not decoration

A dashboard's brand accent color and its semantic colors (success, warning, danger) should never be the same token. If your brand color happens to be green, a "success" state that reuses it stops meaning anything the moment a designer wants a green button somewhere unrelated. I keep a separate, small semantic palette — used only for state, never for branding — so a red badge always means "needs attention" and nothing else has to compete with that signal.

The server-state and UI-state boundary

State boundaries matter as much as styling. Server state — orders, users, licenses, anything that came from a database — belongs in server components or a dedicated data-fetching layer. UI state — an open dropdown, a selected tab, a draft form value — stays local to the component that owns it and never gets promoted to global state just because it was convenient once.

Mixing the two is the single most common reason dashboards become hard to reason about: a `useState` that's quietly become the source of truth for something the server also thinks it owns is where most of the genuinely confusing bugs come from.

Performance: the dashboard that doesn't jank

Dense screens punish naive rendering. A 2,000-row table re-rendering on every keystroke of a filter box, or a page that blocks on the slowest of six data sources before showing anything, both read as "slow" even on fast connections. Three things fix most of it: virtualize long lists so only visible rows mount, memoize derived values (totals, sorted views) instead of recomputing them on every render, and stream slower sections in with Suspense boundaries so the rest of the page isn't held hostage by one report that takes longer to load.

A checklist before you ship a new screen

  • Does the data have a shared TypeScript type, validated once at the boundary?
  • Does every card, tile, and table row reuse an existing primitive instead of new markup?
  • Is color used only where it's a signal — never as pure decoration?
  • Is there a clear owner for every piece of state: server, or local UI?
  • Will a long table stay smooth with real production data volumes, not just the ten test rows?
  • Does the page still make sense with prefers-reduced-motion and no animation at all?
  • Could a new teammate add one more metric tile without inventing a new pattern?

The payoff for all of this is a dashboard that's genuinely pleasant to extend a year in: a new metric or table is a matter of reusing existing primitives and types, not inventing a new pattern every time — which is exactly the point where most dashboards this size have already started to rot.