Skip to content
Rhodie

Rhodie.co.uk


2025Next.js 15, React 19, TypeScript, Tailwind CSS 4, Radix UI, Cloudflare Workers

Rhodie.co.uk

2025 · Next.js 15, React 19, TypeScript, Tailwind CSS 4, Radix UI, Cloudflare Workers

About This Project

I've been trying to build a portfolio site for the better part of a decade. I'd start, spend a day or two on it, get stuck on either the design or the "writing about myself" part, and quietly shelve it. This happened more times than I'd like to admit.

The breakthrough was stupidly simple. I spend most of my working life inside the WordPress admin dashboard. I know every pixel of it. So instead of agonising over layouts and colour schemes, I just... used that. The whole site is designed to look and feel like the WP admin panel, right down to the sidebar, the admin bar, the dashboard widgets, and the "Howdy" in the top right corner. It gave me a design I didn't have to second-guess and a technical challenge that was actually fun to build.

It also doubled as a learning project. Most of my client work is WordPress, but I wanted to build something substantial in Next.js to get properly comfortable with it. A portfolio seemed like the right excuse.

Tech Stack & Approach

The site runs on Next.js 15 with the App Router, React 19, and TypeScript. It's deployed to Cloudflare Workers using OpenNext, which means it's fast, cheap to run, and sits on Cloudflare's edge network.

Tailwind CSS 4 handles all the styling. The design token system mirrors the actual WordPress admin colour scheme, including a full dark mode that swaps out every token:

/* Light mode — matches WP admin defaults */
:root {
  --background: #ffffff;
  --foreground: #1d2327;
  --wp-border: #c3c4c7;
  --wp-box-bg: #ffffff;
  --wp-box-header-bg: #f6f7f7;
  --page-bg: #f0f0f1;
  --wp-link: #2271b1;
  --wp-blue: #2271b1;
  --wp-sidebar-bg: #1d2327;
  --wp-sidebar-text: #f0f0f1;
  --wp-sidebar-current-bg: #2271b1;
}

/* Dark mode — every token swapped */
.dark {
  --background: #0a0a0a;
  --foreground: #ededed;
  --wp-border: rgba(255, 255, 255, 0.12);
  --wp-box-bg: #1e1e1e;
  --wp-box-header-bg: #252525;
  --page-bg: #141414;
}

These aren't arbitrary colours. They're pulled directly from WordPress core's CSS. If you put the real WP admin next to this site, the values match.

The sidebar icons are the same. The site loads the actual WordPress Dashicons font and uses the same Unicode character codes:

const ICON = {
  dashboard: "\uf226",
  home:      "\uf102",
  portfolio: "\uf322",
  tools:     "\uf107",
  post:      "\uf109",
  plugins:   "\uf106",
  users:     "\uf110",
  email:     "\uf466",
} as const;

These are the exact same codes that WordPress uses in its admin menu. The font file is the same one that ships with WordPress. The goal was authenticity, not approximation.

Radix UI powers the interactive components. The command palette (hit Cmd+K), the dialogs, the dropdowns, and the toast notifications are all built on Radix primitives, which means they're accessible out of the box with proper keyboard navigation, focus management, and screen reader support.

Design & User Experience

The goal was to make it feel authentic, not just look like a screenshot. The sidebar navigation, the admin bar, the "Screen Options" tabs, the wp-box widgets on the dashboard. All of these behave the way you'd expect if you've used WordPress.

The font stack is even WordPress's own:

const WP_FONT =
  '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif';

There are some interactive touches that I built mostly for fun. The "Updates" link in the sidebar triggers a fake WordPress update sequence, complete with a progress bar and stage messages, before landing on a maintenance plan pitch. The Quick Draft widget on the dashboard includes a working SEO checker that analyses your title and content for readability, keyword density, and common SEO issues. And then there's the plugin system, which deserves its own section.

A Closer Look: The Plugin System

If you go to the Plugins page, you'll find a "Make it pink" plugin that you can activate. It does exactly what it says. The entire site gets a pink overlay and a font change. Deactivate it and everything goes back to normal. It persists across page loads, and if you've got two tabs open, activating it in one will update the other.

It's obviously a joke, but the engineering behind it isn't. I wanted to mirror how WordPress plugins actually work, where activating a plugin changes the behaviour of the entire site, not just the page you're on. In a React app with no backend, that means solving a few problems at once: persisting state, synchronising it across components that don't share a parent, and keeping multiple browser tabs in sync.

The core of it lives in lib/plugins.ts. There are three layers doing different jobs:

export const PLUGIN_STATE_EVENT = "plugin-state-change" as const;

export type PluginStateChangeDetail = {
  slug: string;
  active: boolean;
};

type PluginStateMap = Record<string, boolean>;
type PluginAwareWindow = Window & { __pluginState?: PluginStateMap };

export function getPluginActivation(slug: string): boolean {
  if (typeof window === "undefined") return false;
  const state = getPluginStateBag();
  if (state && slug in state) {
    return state[slug];
  }
  const active = readStoredPluginActivation(slug);
  if (state) {
    state[slug] = active;
  }
  return active;
}

export function setPluginActivation(slug: string, active: boolean): void {
  if (typeof window === "undefined") return;
  const state = getPluginStateBag();
  if (state) {
    state[slug] = active;
  }
  window.localStorage.setItem(
    pluginStorageKeyInternal(slug),
    active ? "true" : "false"
  );
  window.dispatchEvent(
    new CustomEvent<PluginStateChangeDetail>(PLUGIN_STATE_EVENT, {
      detail: { slug, active },
    })
  );
}

A window-level state bag (__pluginState) acts as an in-memory cache so components can read the current state without hitting localStorage on every render. localStorage handles persistence across page loads. And a custom event (plugin-state-change) fires on every activation change so that any component anywhere in the tree can subscribe and react immediately.

The admin shell then listens for all of it:

React.useEffect(() => {
  const doc = window.document.documentElement;
  const apply = () => {
    doc.classList.toggle(
      "plugin-make-it-pink",
      getPluginActivation(MAKE_IT_PINK_SLUG)
    );
  };
  apply();

  // Same-tab: custom event from setPluginActivation
  const onPluginEvent = (event: Event) => {
    const detail = (event as CustomEvent<PluginStateChangeDetail>).detail;
    if (!detail || detail.slug !== MAKE_IT_PINK_SLUG) return;
    apply();
  };

  // Cross-tab: browser fires StorageEvent when another tab writes
  const onStorage = (ev: StorageEvent) => {
    if (!ev.key || ev.key === MAKE_IT_PINK_STORAGE_KEY) apply();
  };

  window.addEventListener(PLUGIN_STATE_EVENT, onPluginEvent);
  window.addEventListener("storage", onStorage);
  document.addEventListener("visibilitychange", apply);
  window.addEventListener("popstate", apply);

  return () => {
    window.removeEventListener(PLUGIN_STATE_EVENT, onPluginEvent);
    window.removeEventListener("storage", onStorage);
    document.removeEventListener("visibilitychange", apply);
    window.removeEventListener("popstate", apply);
  };
}, []);

Four different listeners, each catching a different edge case. The custom event handles same-tab changes. The StorageEvent handles cross-tab sync (the browser fires this natively when another tab writes to localStorage, which is a lovely API that most people don't know exists). The visibilitychange listener catches you switching back to a tab. And popstate handles browser back/forward navigation.

The end result is that activating a pink plugin on a joke page behaves exactly like activating a real plugin in WordPress. The whole thing is about 80 lines of code with no external dependencies. It's one of those features that looks like a throwaway joke on the surface but needed actual thought to get right.

Data & Content

Content is currently served from local JSON files. The data layer is built so it can be swapped to WPGraphQL when I'm ready, using graphql-request for queries and with the GraphQL codegen tooling already in the project. The idea is that this site could eventually pull content from an actual WordPress backend, which would be a fitting full-circle moment.

Why This Stack?

Partly because it was the right tool for the job. A portfolio is mostly static content with a few interactive bits, and Next.js handles that split well. Server components render the static parts, client components handle the interactive ones, and Cloudflare Workers keeps the whole thing fast at the edge.

But mostly because I wanted to build it. After years of WordPress client work I wanted to prove to myself that I could ship something modern, well-structured, and fun in a completely different stack. The WP-admin concept meant I could focus on the engineering without getting stuck on design decisions again. And it turns out that building a fake WordPress dashboard from scratch is a pretty good way to show you understand the real one.