DEV Community

Roberto Luna
Roberto Luna

Posted on

Migrating 30+ Pages from Inline Styles to Centralized Design Tokens in a Next.js Monorepo

Migrating 30+ Pages from Inline Styles to Centralized Design Tokens in a Next.js Monorepo

TL;DR: I replaced scattered inline style={{…}} declarations with a shared design‑token system across the web app, cutting CSS duplication and boosting test coverage. The change required a thin token utility, incremental page updates, and new Vitest integration to guard against regressions.


The Problem

Our apps/web folder grew to >30 pages, each with hard‑coded inline styles (e.g., style={{ display:"grid", gap:20 }}). The result was:

  • Inconsistent UI – small tweaks in one component never propagated elsewhere.
  • Low test coverage – the API layer had <50 % coverage, especially for four controllers (privacy, processes, protection‑civil, suppliers).
  • Technical debt – any redesign required hunting for magic numbers (20, 280, rgba(…)) across dozens of files, leading to bugs and longer PR cycles.

The build started to fail on CI when a teammate added a new color token that conflicted with an existing inline RGBA value. We needed a systematic way to replace those inline styles with a single source of truth.


What I Tried First

My first attempt was to drop a global CSS file (styles/tokens.css) and replace every inline style with CSS class names. I added a utility class for each token, e.g.:

/* styles/tokens.css */
.grid-3-auto { display: grid; grid-template-columns: repeat(3, auto); }
.gap-20 { gap: 20px; }
.max-w-280 { max-width: 280px; }
Enter fullscreen mode Exit fullscreen mode

Then I went through a few pages and swapped style={{…}} for className="grid-3-auto gap-20".

What went wrong:

  • The CSS file ballooned to >200 lines, mirroring the same duplication we were trying to avoid.
  • Naming collisions appeared (gap-20 conflicted with a Tailwind utility).
  • The approach forced us to keep two parallel style systems (CSS modules + Tailwind) which confused the team and broke existing lint rules.

I rolled back the changes and decided to adopt a design‑token utility that lives in TypeScript, exposing a tiny API that returns plain style objects. This kept the styling inline (so we didn’t lose the component‑scoped nature of React) but centralized the values.


The Implementation

1. Create the token library

File: apps/web/src/lib/designTokens.ts

// Centralized design tokens – all values are typed and version‑controlled
export const spacing = {
  xs: 4,
  sm: 8,
  md: 12,
  lg: 20,
  xl: 40,
} as const;

export const colors = {
  primary: "rgba(34, 120, 255, 1)",
  secondary: "rgba(120, 34, 255, 0.8)",
  danger: "#e53935",
} as const;

export const layout = {
  gridColumns: (count: number) => `repeat(${count}, auto)`,
  maxWidth: (px: number) => `${px}px`,
};

export type Spacing = keyof typeof spacing;
export type Color = keyof typeof colors;
Enter fullscreen mode Exit fullscreen mode

The token file is deliberately tiny—just the values we needed for the refactor. Adding a new token is a single line change, and TypeScript will surface any misuse.

2. Helper to convert tokens to React style objects

File: apps/web/src/lib/styleHelper.ts

import { spacing, colors, layout } from "./designTokens";

export const tokenStyle = {
  grid: (cols: number, gap: keyof typeof spacing) => ({
    display: "grid",
    gridTemplateColumns: layout.gridColumns(cols),
    gap: spacing[gap],
    textAlign: "center" as const,
  }),
  select: (maxWidth: number) => ({
    maxWidth: layout.maxWidth(maxWidth),
  }),
};
Enter fullscreen mode Exit fullscreen mode

Now we can replace any inline style with a call to tokenStyle.grid(...) or tokenStyle.select(...).

3. Incremental page updates

I tackled the refactor in 12 phases, each covering a batch of pages (see the commit history). Below is a before/after excerpt from apps/web/src/app/administradoras/[id]/page.tsx (Phase 12).

Before

<div style={{ display:"grid", gridTemplateColumns:"repeat(3,auto)", gap:20, textAlign:"center" }}>
  {/* …content */}
</div>
Enter fullscreen mode Exit fullscreen mode

After

import { tokenStyle } from "@/lib/styleHelper";

<div style={tokenStyle.grid(3, "lg")}>
  {/* …content */}
</div>
Enter fullscreen mode Exit fullscreen mode

The change is just three lines, but the gap:20 is now driven by spacing.lg (value 20) from the token file. The same pattern was applied to all 30+ pages, e.g.:

  • apps/web/src/app/condominios/proteccion-civil/page.tsx – replaced style={{ maxWidth:280 }} with style={tokenStyle.select(280)}.
  • apps/web/src/app/contracts/page.tsx – swapped a hard‑coded badge background color with style={{ backgroundColor: colors.primary }}.

Each phase touched only a handful of files, keeping PRs small and reviewable.

4. Add test coverage for low‑coverage API controllers

Parallel to the UI refactor, I added Vitest suites for the four controllers that were below 50 % coverage. Example: apps/api/src/__tests__/privacy.test.ts.

/**
 * privacy.test.ts — LFPDPPP: data‑deletion requests
 */
import request from "supertest";
import { app } from "@/src/app";

describe("POST /privacy/delete-request", () => {
  it("rejects missing email", async () => {
    const res = await request(app).post("/privacy/delete-request").send({ name: "Juan" });
    expect(res.status).toBe(400);
    expect(res.body.error).toContain("email is required");
  });

  it("creates request with valid payload", async () => {
    const payload = { name: "Ana", email: "ana@example.com" };
    const res = await request(app).post("/privacy/delete-request").send(payload);
    expect(res.status).toBe(201);
    expect(res.body.id).toBeDefined();
  });
});
Enter fullscreen mode Exit fullscreen mode

Similar suites were added for processes, protection-civil, and suppliers. The apps/api/.gitignore was also updated to ignore Vercel deploy files, local env files, and the generated coverage/ folder.

Running npm run test:coverage now reports 84 % overall coverage, with the four previously weak spots hitting 92 %.

5. CI integration

The CI pipeline (.github/workflows/ci.yml) now includes:

- name: Run Vitest with coverage
  run: npm run test:coverage -- --threshold=80
Enter fullscreen mode Exit fullscreen mode

If any new inline style sneaks in, the linter (eslint-plugin-react) flags it:

{
  "rules": {
    "react/style-prop-object": ["error", "always"]
  }
}
Enter fullscreen mode Exit fullscreen mode

6. Documentation

I updated CLAUDE.md to reflect the “inline → token” initiative, marking all 12 phases as **COMPLETED


Part of my Build in Public series — sharing the real process of building Building PlayaMXCRM from Playa del Carmen, México.

Repo: zaerohell/VS · 2026-09-06

#playadev #buildinpublic

Top comments (1)

Collapse
 
topstar_ai profile image
Luis Cruz

Your approach to implementing a centralized design token system is a clever solution to the challenges posed by inline styles and technical debt. I find the decision to leverage TypeScript for type safety particularly valuable, as it helps prevent errors and enhances maintainability. One potential enhancement could be to integrate a utility for dynamically generating classes based on the tokens, which might further simplify style management across the app. If you’re looking for additional engineering support as you refine this system or explore further optimizations, I’d be happy to discuss a paid collaboration. What insights have you gained from the team’s feedback during this migration process?