Skip to content

Styling with Tailwind CSS

Aman Kumar Singh8 min read
  • tailwindcss
  • react
  • css
  • webdev
  • frontend
  • nextjs
  • softwareengineering
Part 24 of 50From the React & Next.js Complete Guide series
Styling with Tailwind CSS — article by Aman Kumar Singh

The previous article in this series covered rendering strategies: when to render on the server, when to prerender at build time, and when incremental static regeneration splits the difference. None of that matters if the page that finally reaches the browser looks inconsistent or takes a redesign-sized effort to change a spacing value. This article is about the layer that decides how fast your team can actually ship UI: the styling approach.

This is part of the React & Next.js Complete Guide, and it covers Tailwind CSS the way I'd actually set it up for a production SaaS app, not the marketing-site demo version. Config as a design system, variant composition that doesn't turn into className soup, and the handful of production pitfalls that catch teams a few months into a project rather than on day one.

I've used Tailwind on projects that started as a prototype and grew into the primary product. The pattern that holds up is treating the config file as the single source of truth for design tokens, then leaning on a small set of composition utilities so components stay readable as the app grows. Skip either half and Tailwind starts to feel like exactly the mess critics describe: long, unreadable className strings with no consistency across the app.

Why utility-first still wins for production teams

The standard objection to Tailwind is that it moves styling logic into markup, and that objection isn't wrong on its face. A <div className="flex items-center justify-between rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900"> is not pretty to look at. The counterargument is what happens to the alternative at scale.

With hand-written CSS or CSS Modules, every new component is a naming decision (.card, .card-header, .card-header--compact) and a specificity decision. Six months in, a codebase with multiple contributors accumulates dead selectors, near-duplicate classes with slightly different padding, and a design system that only exists in someone's head. Utility classes remove the naming problem entirely: there's one canonical way to express "16px padding" or "flex row, centered," and it's the same in every file. The tradeoff is verbosity in the markup in exchange for consistency you don't have to enforce through code review discipline.

CSS-in-JS libraries solve the naming problem differently, by scoping styles to the component, but historically came with a runtime cost: generating and injecting styles on every render unless you reach for a build-time extraction step. Tailwind's utility classes are static strings the compiler can scan and generate CSS for ahead of time, so there's no runtime style computation at all. That's a real production advantage.

The pitfall on the other side: utility-first only pays off if your team actually agrees to use the utilities as the design system, rather than reaching for arbitrary values (w-[437px]) every time a utility doesn't match a mockup pixel-for-pixel. If arbitrary values become the norm, you've recreated inline styles with extra syntax and lost the main benefit.

Configuring Tailwind as your design system

The config file is where Tailwind stops being "a set of CSS shortcuts" and starts being an actual design system. Every color, spacing value, font size, and breakpoint your product uses should be defined once here, not invented ad hoc in components.

// tailwind.config.ts
import type { Config } from "tailwindcss";

const config: Config = {
  content: [
    "./src/app/**/*.{ts,tsx}",
    "./src/components/**/*.{ts,tsx}",
  ],
  darkMode: "class",
  theme: {
    extend: {
      colors: {
        brand: {
          50: "#f0f5ff",
          100: "#dbe4ff",
          500: "#3b5bfd",
          600: "#2f47cc",
          700: "#26389f",
        },
        surface: {
          DEFAULT: "#ffffff",
          muted: "#f8fafc",
          dark: "#0f172a",
        },
      },
      spacing: {
        18: "4.5rem",
      },
      borderRadius: {
        card: "0.75rem",
      },
      fontFamily: {
        sans: ["var(--font-inter)", "system-ui", "sans-serif"],
      },
    },
  },
  plugins: [],
};

export default config;

Two decisions here matter more than they look like they should. First, content needs to point at every file that can contain a Tailwind class name, including files under components/, not just app/. Tailwind's JIT compiler scans these files as plain text looking for class name patterns; anything outside the glob simply won't generate CSS, and the failure mode is a component that silently renders unstyled in production while working fine in dev because you happened to also use that class elsewhere.

Second, naming semantic tokens (brand, surface) instead of only using Tailwind's default palette (blue-500, slate-100) directly in components. If the brand color changes, you edit one line in the config instead of running a find-and-replace across the codebase. This is the same reasoning that justifies design tokens in any design system. Tailwind's config is just where you write them down for a utility-first approach.

Composing variants without className soup

Writing utility classes is the easy part. The real skill with Tailwind at scale is managing variants of a component (a button that's primary, secondary, or destructive; small, medium, or large) without every combination turning into a wall of conditional string concatenation.

class-variance-authority (cva) solves this directly, and pairing it with tailwind-merge handles the case where a consumer needs to override a default class without fighting specificity.

// components/ui/button.tsx
import { cva, type VariantProps } from "class-variance-authority";
import { twMerge } from "tailwind-merge";
import { forwardRef, type ButtonHTMLAttributes } from "react";

const buttonVariants = cva(
  "inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 disabled:pointer-events-none disabled:opacity-50",
  {
    variants: {
      variant: {
        primary: "bg-brand-600 text-white hover:bg-brand-700",
        secondary: "bg-surface-muted text-slate-900 hover:bg-slate-200",
        destructive: "bg-red-600 text-white hover:bg-red-700",
      },
      size: {
        sm: "h-8 px-3 text-sm",
        md: "h-10 px-4 text-sm",
        lg: "h-12 px-6 text-base",
      },
    },
    defaultVariants: {
      variant: "primary",
      size: "md",
    },
  }
);

type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> &
  VariantProps<typeof buttonVariants> & {
    className?: string;
  };

export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
  ({ className, variant, size, ...props }, ref) => {
    return (
      <button
        ref={ref}
        className={twMerge(buttonVariants({ variant, size }), className)}
        {...props}
      />
    );
  }
);

Button.displayName = "Button";

This pattern gives you a component with a fixed, typed set of variants (TypeScript will complain if someone passes variant="warning" when it doesn't exist), and it still lets a consumer pass a className prop to adjust one-off cases without the override silently losing to Tailwind's own classes. twMerge understands Tailwind's utility conflicts specifically: it knows px-4 and px-6 are the same category and resolves to the last one, whereas plain string concatenation would leave both in the class list and let CSS specificity or source order decide. That's exactly the kind of bug that's hard to reproduce, because it depends on class order.

The tradeoff is an extra dependency and a small amount of setup per component. For a design system with more than a handful of components and variants, that setup pays for itself the first time someone needs to add a new variant without touching five other files.

Responsive design, dark mode, and container queries

Tailwind's responsive prefixes (sm:, md:, lg:) are mobile-first: an unprefixed utility applies at all sizes, and a prefixed one overrides it from that breakpoint up. This is straightforward once internalized, but engineers coming from desktop-first CSS frameworks sometimes write the logic backward and end up fighting the cascade.

Dark mode with darkMode: "class" (rather than the media strategy) means dark mode is controlled by adding a dark class to a parent element, usually <html>, based on a stored user preference, not purely the OS setting. This matters in production because it lets users override the system preference, and it avoids a flash of the wrong theme if you read the stored preference during SSR and set the class before first paint.

Container queries, supported through the @tailwindcss/container-queries plugin or natively depending on your Tailwind version, solve a real limitation of viewport-based breakpoints: a card that needs to look different in a narrow sidebar versus a wide main content area can't express that with md: and lg:, because those respond to the viewport, not the component's own container. This is a useful escape hatch, but I'd reach for it only when a component actually lives in more than one layout context. Defaulting to it everywhere adds indirection most components don't need.

Production pitfalls that show up months in

The most common failure is dynamically constructed class names the JIT compiler can't see. A pattern like `text-${color}-500` looks reasonable, but Tailwind's scanner works on static text, not evaluated JavaScript, so it never generates text-red-500 or text-blue-500 unless those exact strings appear literally in the scanned files. The fix is a lookup object mapping known keys to full static class strings.

const statusStyles = {
  active: "bg-green-100 text-green-800",
  pending: "bg-yellow-100 text-yellow-800",
  failed: "bg-red-100 text-red-800",
} as const;

The second pitfall is arbitrary value overuse becoming the path of least resistance under deadline pressure. A handful of w-[437px] values for one-off layout quirks is fine. When arbitrary values start outnumbering theme-based utilities, the config has stopped functioning as a design system and every component is back to inventing its own numbers, just with square brackets instead of a separate CSS file.

The third is assuming Tailwind's output size scales with how much markup you write. The compiler generates CSS only for the utility classes actually present across your scanned files, deduplicated, so a large app with a disciplined, reused set of utilities produces a smaller stylesheet than a small app that leans on arbitrary values and one-off combinations everywhere. Bundle size here is a function of variety, not volume of components.

Key takeaways

  • Treat `tailwind.config.ts` as your design system's source of truth: colors, spacing, and radii belong there as named tokens, not as ad hoc values scattered through components.
  • The `content` glob has to cover every file that can contain a Tailwind class name, or those classes silently generate no CSS in production while looking fine in local dev.
  • Use `class-variance-authority` for typed component variants and `tailwind-merge` to let consumers override default classes without specificity fights.
  • Dynamically constructed class names (`` `text-${color}-500` ``) aren't visible to Tailwind's static scanner; use a lookup object of full, literal class strings instead.
  • Container queries are a real tool for components that render in more than one layout context, but they add indirection that most components don't need.
  • Stylesheet size tracks the variety of utilities used across the app, not the number of components, so a disciplined, reused set of tokens keeps output smaller than scattered arbitrary values.

Frequently asked questions

Does Tailwind CSS hurt performance because of all the classes in the HTML?

No. Tailwind ships a compiled stylesheet generated at build time from the utility classes found in your source files, so there's no runtime style computation, and the output is typically smaller than a comparable hand-written stylesheet since utilities are reused across components instead of duplicated.

Should I use Tailwind alongside CSS Modules or styled-components in the same project?

It's possible, but it reintroduces the naming and specificity coordination problem Tailwind is meant to remove. If you're adopting Tailwind, migrate incrementally by file or feature rather than running two systems side by side indefinitely.

How do I keep long className strings readable in components?

Extract repeated groups of utilities into variant functions with `cva`, or into small wrapper components, rather than reaching for `@apply` in a separate CSS file. `@apply` reintroduces the naming problem Tailwind exists to avoid.

Is Tailwind a good fit for a component library shared across multiple apps?

Yes, as long as the consuming apps share the same `tailwind.config` tokens, or the library ships its own config that gets merged in. Without shared tokens, a shared component can render with a different visual identity per app, which defeats the purpose of sharing it.

What's the right way to handle a design that needs a value outside the default scale?

Add it to the theme's `extend` block if it's going to be reused, so it becomes a named token like any other. Reach for an arbitrary value (`mt-[13px]`) only for a genuine one-off, and treat a growing number of them as a signal the theme needs updating.

Do I need PostCSS configured separately when using Tailwind with Next.js?

Next.js's built-in Tailwind support handles the PostCSS pipeline automatically once you have a `tailwind.config.ts` and the Tailwind directives in your global CSS file. A manual PostCSS config is only needed for plugins beyond Tailwind's own, like `postcss-nesting`.

Related articles

Design Tokens and Theming — Aman Kumar Singh
Core Web Vitals for React Apps — Aman Kumar Singh
Image and Asset Optimization — Aman Kumar Singh

Explore more on

Aman Kumar Singh

About the author

I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.