Quick start
If you don't care about how it works, the recipe is:
- Download
patch-admin.jsand drop it atscripts/patch-admin.jsin your Medusa project. - Drop your brand image next to it as
scripts/logo.png(or.jpg). - Add
"postinstall": "node scripts/patch-admin.js"to yourpackage.json. - Open the script and edit
BRAND_NAMEandBRAND_TITLEnear the top. - Run
npm install. Thenmedusa developand 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:
- The logo on the login screen
- The favicon
- The browser tab title (
Medusa Admin) - The "Welcome to Medusa" copy on the login form
- The string
Medusaanywhere else in the UI
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:
- Drop a script in
scripts/patch-admin.js. - Drop your logo next to it.
- Wire it up to
postinstallinpackage.json. - 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:
- Find the unique
dsubstring in the file. - Walk backwards to the nearest
"svg"literal, then to the opening(of the call. - Walk back further to figure out whether it's
jsxs(...)or(0, jsxRuntime.jsxs)(...), recording the call's start. - 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. - 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
- Pinned to Medusa 2.13.x. The SVG signatures (
M238.088 51.1218,M30.85 6.16832) are the first path coordinates in Medusa's current logos. If Medusa redraws either logo on a future release, those strings change and Patch 1 won't find them. Same for the bundler's favicon placeholder line. - It edits
node_modules. If your CI hashesnode_modulesto enforce reproducible installs, the post-postinstalltree won't byte-match a vanilla install. The script is deterministic, so the hash will be stable across runs, just not equal to the upstream hash. If you usepnpmwithenableScripts: falseor similar, the postinstall won't run and you'll need to invoke it explicitly. - It's not a fork, but it's also not officially supported. If Medusa ships proper theming hooks, throw the script away. Until then, it's the cleanest way I've found.
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.