the medusa admin has no theming hooks. i wrote a postinstall patch instead.

may 1, 20269 min read

Quick start

If you don't care about how it works, the recipe is:

  1. Download patch-admin.js and drop it at scripts/patch-admin.js in your Medusa project.
  2. Drop your brand image next to it as scripts/logo.png (or .jpg).
  3. Add "postinstall": "node scripts/patch-admin.js" to your package.json.
  4. Open the script and edit BRAND_NAME and BRAND_TITLE near the top.
  5. Run npm install. Then medusa develop and log in to see the new branding.

The rest of this post is why the script has to exist and what the three patches actually do. Read it before running the thing in production.

How I got here

I was building a Medusa storefront for a client who, very reasonably, did not want their merchant team logging into something called "Medusa Admin." The logo, the page title, the login screen, the welcome string. All Medusa, all the time.

So I went looking for the theming hooks. There aren't any.

What Medusa actually exposes

Medusa's admin extension story is built around two things: widget injection zones (drop a React component into a pre-defined slot on a page) and custom routes (your own pages under /app/...). Both are great for what they're for. Neither of them touches:

Those are not customization slots. They're rendered by the compiled bundle that ships in node_modules/@medusajs/dashboard/dist/ as a tree of pre-minified .mjs files. To change them officially, you fork the dashboard package and rebuild it. You also inherit the maintenance burden of keeping that fork in sync with upstream releases, which on a 2.x project that's still moving fast is not a small thing.

I didn't want a fork. I wanted to keep @medusajs/dashboard on the version manifest with no patches, install it like any other dependency, and somehow end up with the right branding on the page anyway.

The shape of the workaround

The bundle is just JavaScript in node_modules. You can read it. You can write to it. If you can do both reliably enough that an npm install reapplies the changes, you have a "patch" in the practical sense: no fork, no wrapper, just a script that runs after deps resolve.

So the plan is:

  1. Drop a script in scripts/patch-admin.js.
  2. Drop your logo next to it.
  3. Wire it up to postinstall in package.json.
  4. Every install, the script walks node_modules/@medusajs/dashboard/dist, swaps the logo SVGs for an <img> of your file, replaces the text, and patches the HTML template in @medusajs/admin-bundler.

There are three patches the script applies, and only one of them is interesting.

Patch 1: the logo SVG

Both the login AvatarBox and the reset-password LogoBox render their logos as inline SVG. In the compiled output that comes through as a JSX runtime call:

/* @__PURE__ */ jsxs(
  "svg",
  { className: "...", viewBox: "0 0 400 400", children: [ /* path nodes */ ] }
)

In the CJS chunk (app.js) it shows up as (0, import_jsx_runtime698.jsxs)("svg", { ... }). You can't regex-replace this. The children array holds nested JSX calls with their own parens and string literals, and the call itself can be wrapped in (0, foo)(...) indirection.

The trick I landed on: anchor on something unique inside the SVG, then parse the call boundaries by hand.

Each Medusa logo has a path with a distinctive d attribute, a long string of bezier coordinates. Those strings are stable across builds and don't appear anywhere else in the bundle. So:

  1. Find the unique d substring in the file.
  2. Walk backwards to the nearest "svg" literal, then to the opening ( of the call.
  3. Walk back further to figure out whether it's jsxs(...) or (0, jsxRuntime.jsxs)(...), recording the call's start.
  4. Walk forwards from the opening paren, counting parens, skipping over string literals (so a ( inside "foo(" doesn't break the count), until depth returns to zero.
  5. Replace the entire range with jsx("img", { src: <data-uri>, ... }) using the same runtime symbol you found in step 3.

The paren walker is short:

const findMatchingParen = (text, openIdx) => {
  let depth = 0
  let i = openIdx
  while (i < text.length) {
    const c = text[i]
    if (c === '"' || c === "'" || c === "`") {
      const q = c
      i++
      while (i < text.length && text[i] !== q) {
        if (text[i] === "\\") i++
        i++
      }
    } else if (c === "(") depth++
    else if (c === ")") {
      depth--
      if (depth === 0) return i
    }
    i++
  }
  return -1
}

Not pretty. It's not a parser. It's the smallest amount of bracket-counting that survives minified JSX with literal ( and ) inside attributes. It works because we're not trying to understand the code, just to find where one specific call ends.

The replacement is a single jsx("img", ...) with the brand image inlined as a data:image/png;base64,... URI. Inlining means there's no second file to keep in sync, no path resolution, no Vite asset pipeline to convince. The image is a string embedded in JS.

Patch 2: favicon and title

@medusajs/admin-bundler ships an HTML template with one relevant line:

<link rel="icon" href="data:," data-placeholder-favicon />

…and no <title>. The title gets set later, dynamically, but the initial document has none, which is why you'll see localhost:9000 in the browser tab during the first paint.

This part is regex. The placeholder favicon has a unique attribute (data-placeholder-favicon); the script swaps it for a real <link rel="icon" type="image/png" href="<data-uri>" /> and inserts a <title> immediately after. Idempotent because the regex stops matching once the placeholder is gone, and the title insertion checks for its own output before adding.

One gotcha: medusa develop writes a copy of this template to .medusa/client/index.html on startup. If a stale version of that file is already on disk from before the patch ran, the dev server will keep serving the unpatched HTML. The script deletes the stale copy if it sees one, so the next medusa develop regenerates it from the fixed template.

Patch 3: text strings

Easier. The script grep-finds the dist files containing Welcome to Medusa, then runs two replacements:

next = next.replace(/\bMedusa Admin\b/g, `${BRAND_NAME} Admin`)
next = next.replace(/\bMedusa\b/g, BRAND_NAME)

Longer pattern first. With these particular replacements both orders happen to converge, since \bMedusa\b would match the Medusa inside Medusa Admin either way. But the moment you change the second replacement to anything other than ${BRAND_NAME} Admin (say you want ${BRAND_NAME} Console instead), running the longer match first becomes the only correct order, and I'd rather not depend on remembering that.

That's all the user-facing surfaces I found after walking through every screen of the admin. If you find more, the same pattern extends.

What it looks like in your repo

Once it's wired up, your project gains two files under scripts/:

your-medusa-app/
├── scripts/
│   ├── patch-admin.js
│   └── logo.png
├── package.json
└── ...

logo.png is your brand mark; anything roughly square works, and the script base64-encodes it at runtime. PNG, JPG, and JPEG are all accepted.

In package.json, one line:

{
  "scripts": {
    "postinstall": "node scripts/patch-admin.js",
    "dev": "medusa develop",
    "build": "medusa build"
  }
}

Open scripts/patch-admin.js and edit two constants near the top:

const BRAND_NAME = "Acme"
const BRAND_TITLE = "Acme Admin"

Run npm install. The script logs each patch as it lands:

[patch-admin] encoded logo.png (24,816 chars)
[patch-admin] replaced AvatarBox SVG in app.js
[patch-admin] replaced LogoBox SVG in reset-password-DCl9.mjs
[patch-admin] rebranded text in en.json
[patch-admin] patched admin-bundler HTML template (favicon + title)
[patch-admin] cleared Vite cache at node_modules/.vite
[patch-admin] done.

If a step doesn't fire (say you bumped Medusa and one of the SVG signatures shifted), you'll see exactly which one failed. The script is intentionally noisy on success and silent on no-op so drift is easy to spot.

Caveats, because there always are some

Try it yourself

Download the script: patch-admin.js.

Drop it in scripts/, drop your logo.png next to it, add the postinstall line to package.json, edit the two brand constants, run npm install. The whole thing is under 250 lines. Read it before you run it. It does write to node_modules.

If your admin tab says "Acme Admin" instead of "Medusa Admin" the next time you log in, it worked.

← back to blog