Theme Plugin
What's a theme plugin?β
Verdaccio uses by default a custom UI that provides a good set of feature to visualize the packages, but might be case your team needs some custom extra features and here is where a custom theme is an option. The plugin store static assets that will be loaded in the client side when the page is being rendered.
How a theme plugin is loadedβ
A theme that fails to load does not stop Verdaccio. Whatever goes wrong β the package is
not installed, the name is misspelled, the export is missing a required property β the loader
writes one line to the log and moves on, and the web UI comes up with the default theme.
The startup then reports plugin @verdaccio/ui-theme successfully loaded, which is true and
also exactly what you would see if your plugin had never been configured.
So when a custom theme "does nothing", read the log rather than the screen. The two lines to look for are:
error <name> doesn't look like a valid plugin
error package not found, try to install <name> with a package manager
Two more rules the loader applies:
- The package name must start with
verdaccio-theme-, unlessserver.pluginPrefixsays otherwise. Anything else is not even looked up. - Only one theme is used. Configuring several logs
multiple ui themes are not supported; only the first plugin is usedand takes the first.
The validity check is exactly this β all three properties, or the plugin is discarded:
plugin.staticPath && plugin.manifest && plugin.manifestFiles;
How the assets of the theme loads?β
By default the application loads on http://localhost:4873, but in cases where a resverse proxy with custom domain are involved the assets are loaded based on the property __VERDACCIO_BASENAME_UI_OPTIONS.base and __VERDACCIO_BASENAME_UI_OPTIONS.basename, thus only one domain configuration can be used.
The theme loads only in the client side, the application renders HTML with <script> tags to render the application, the bundler takes care of load any other assets as svg, images or chunks associated with it.
The __VERDACCIO_BASENAME_UI_OPTIONS objectβ
window.__VERDACCIO_BASENAME_UI_OPTIONS is available in the browser global context. Its
shape is the TemplateUIOptions type, defined in
@verdaccio/types:
type TemplateUIOptions = {
uri?: string;
protocol?: string;
host?: string;
base: string;
basename?: string; // deprecated, use base
version?: string;
flags?: FlagsConfig;
} & CommonWebConf;
CommonWebConf carries what the web block of config.yaml sets β title, logo,
logoDark, favicon, darkMode, language, login, scope, pkgManagers,
sort_packages, and the show* toggles (showInfo, showSettings, showSearch,
showFooter, showThemeSwitch, showDownloadTarball, showUplinks).
// output example
{
"darkMode": false,
"base": "https://registry.my.org/",
"primaryColor": "#4b5e40",
"version": "9.0.0",
"pkgManagers": ["yarn", "pnpm", "npm"],
"login": true,
"logo": "",
"title": "Verdaccio Registry",
"scope": "",
"language": "en-US"
}
Theme Configurationβ
By default verdaccio loads the @verdaccio/ui-theme which is bundled in the main package, if you want to load your custom plugin has to be installed where could be found.
$> npm install --global verdaccio-theme-dark
The plugin name prefix must start with verdaccio-theme-xxx, otherwise the plugin will be ignored.
You can load only one theme at a time (if more are provided the first one is being selected) and pass through options if you need it.
theme:
dark:
option1: foo
option2: bar
These options will be available
Plugin structureβ
If you have a custom user interface theme has to follow a specific structure:
{
"name": "verdaccio-theme-xxxx",
"version": "1.0.0",
"description": "my custom user interface",
"main": "index.js",
}
The main file index.js file should contain the following content.
module.exports = () => {
return {
// location of the static files, webpack output
staticPath: path.join(__dirname, 'static'),
// webpack manifest json file
manifest: require('./static/manifest.json'),
// main manifest files to be loaded
manifestFiles: {
js: ['runtime.js', 'vendors.js', 'main.js'],
},
};
};
If any of the following properties are not available, the plugin won't load, thus follow this structure.
staticPath: is the absolute/relative location of the statics files, could be any path either withrequire.resolveor build your self, what's important is inside of the package or any location that the Express.js middleware is able to find, behind the scenes theres.sendFileis being used.manifest: A Webpack manifest object.manifestFiles: A object with one propertyjsand the array (order matters) of the manifest id to be loaded in the template dynamically.- The
manifestFilesrefers to the main files must be loaded as part of thehtmlscripts in order to load the page, you don't have to include the chunks since are dynamically loaded by the bundler.
Manifest fileβ
Verdaccio resolves each entry in manifestFiles against the manifest object and injects
the result into the HTML. The lookup is a plain property access β manifest[name] β and the
value has to be a string path:
{
"main.js": "/-/static/main.4f2a1c.js",
"main.css": "/-/static/main.9b3e77.css",
"favicon.ico": "/-/static/favicon.ico"
}
So there is one supported manifest shape, not one per bundler: a flat map from name to
path. manifestFiles.js and .css are arrays of keys into it, .ico a single key, and
order matters for js.
Any bundler works, as long as it emits that shapeβ
webpack-manifest-plugin produces it directly, which is why it is the usual example:
const { WebpackManifestPlugin } = require('webpack-manifest-plugin');
plugins: [
new WebpackManifestPlugin({
// optional, depends on your implementation
removeKeyHash: true,
}),
];
Vite's built-in manifest does not. With build.manifest, Vite emits entries keyed by
source path whose values are objects ({ file, imports, css }), so manifest[name] gives
you [object Object] in the <script src>.
The default theme is built with Vite and solves this with a small generateBundle plugin
that writes the flat shape itself β read
vite.config.mjs
in @verdaccio/ui-theme, it is about thirty lines and it is the reference for any bundler
Verdaccio does not document.
Its index.js
is worth reading next to it: rather than hardcoding a file list, it derives manifestFiles
from the manifest keys, so a hash change never needs a code change.
Web endpointsβ
A theme runs in the browser, so everything it shows comes from a small set of web endpoints that exist for the user interface. They are separate from the npm registry API that package managers use, they are what the default theme calls, and they are the contract a custom theme can build on.
The routes below are the same on the 6.x line and on later ones. The request and response details were checked against the current code, and the few differences between lines are marked.
Build every URL from base in __VERDACCIO_BASENAME_UI_OPTIONS
instead of hardcoding /: it already includes the domain and the url_prefix when Verdaccio runs
behind a reverse proxy, and it ends with a /.
What Verdaccio serves for your themeβ
| Path | What it returns |
|---|---|
/ and /-/web/* | The HTML page that loads your theme. Every client-side route of your app can live under /-/web/. |
/-/static/* | The files in your plugin's staticPath. |
/-/static/ui-options.js | A script that defines window.__VERDACCIO_BASENAME_UI_OPTIONS. It is never cached. |
/-/assets/* | The files in web.assetFolder, only when that option is set (7.x and later). |
/-/verdaccio/data/* | The data endpoints: packages, search, package details and README. |
/-/verdaccio/sec/* | The security endpoints: login, sign up and change password. |
Authenticationβ
- Requests are anonymous unless they carry a token. After a successful login, send
Authorization: Bearer <token>with every request, using thetokenthe login endpoint returned. - The same access rules as the registry apply, so a user only sees the packages the
accessrules allow, see package access. Packages that are not allowed are left out of the lists, and a direct request for one answers403. - With
web.login: falsethe security endpoints are not available and the package pages are evaluated as an anonymous user.
Data endpointsβ
All of them are GET and answer JSON, except the README.
| Endpoint | Purpose |
|---|---|
/-/verdaccio/data/packages | The packages the user can see. |
/-/verdaccio/data/search/:text | Search the packages, 20 results at most. |
/-/verdaccio/data/sidebar/:package | The details of one package. |
/-/verdaccio/data/package/readme/:package | The README of one package, as text. |
Scoped packages keep the @ in the path, for example /-/verdaccio/data/sidebar/@verdaccio/ui.
sidebar and readme accept ?v= with a version or a dist-tag; anything else answers 404.
These endpoints are rate limited, by default to 5000 requests every 2 minutes per client. Change
it with web.rateLimit (windowMs and max), see the web configuration.
GET /-/verdaccio/data/packagesβ
An array with one entry per package: the fields of its latest version (always name and version,
plus whatever its package.json provides, such as description and author). The author is
normalised to { name, email, avatar }, and dist.tarball points to this registry. The list is
sorted by name, in the direction set by web.sort_packages.
[
{
"name": "@verdaccio/ui",
"version": "2.4.0",
"description": "Shared React components",
"author": { "name": "Jane Doe", "email": "jane@verdaccio.dev", "avatar": "https://β¦" },
"dist": { "tarball": "https://registry.example.com/@verdaccio/ui/-/ui-2.4.0.tgz" }
}
]
GET /-/verdaccio/data/search/:textβ
An array of search results, shaped like the entries of the npm search endpoint: each one has a
package object with at least name, version and description. It returns at most 20 results
and only the packages the user is allowed to see.
GET /-/verdaccio/data/sidebar/:packageβ
The manifest of the package, without readme, _attachments, _rev and name (the name is the
one in the URL), plus a latest object with the version that was asked for. latest is the
version or dist-tag in ?v=, or else dist-tags.latest, or else the highest version. author is
normalised as above and every dist.tarball points to this registry.
{
"latest": {
"version": "2.4.0",
"description": "Shared React components",
"license": "MIT",
"author": { "name": "Jane Doe", "email": "jane@verdaccio.dev", "avatar": "https://β¦" },
"dependencies": { "react": "^19.0.0" },
"dist": { "tarball": "https://registry.example.com/@verdaccio/ui/-/ui-2.4.0.tgz" }
},
"dist-tags": { "latest": "2.4.0", "next": "3.0.0-beta.1" },
"versions": { "2.4.0": {}, "2.3.1": {} },
"time": { "2.4.0": "2026-09-01T10:00:00.000Z" }
}
versions holds the full manifest of every version, and _uplinks is present when the package
comes from an uplink. Treat any field that comes from package.json as optional.
GET /-/verdaccio/data/package/readme/:packageβ
The README as Markdown, with Content-Type: text/plain; charset=utf-8, not JSON. Verdaccio
looks for it in the requested version, then in the latest version, then in the package itself.
When there is none the answer is still 200 with the text ERROR: No README data found!.
Security endpointsβ
They exist only when the web login is enabled (web.login is not false).
| Endpoint | Available when | Purpose |
|---|---|---|
POST /-/verdaccio/sec/login | always | Log in and get a token. |
PUT /-/verdaccio/sec/signup | the createUser flag | Create a user. |
PUT /-/verdaccio/sec/reset_password | the changePassword flag | Change your own password. |
POST /-/verdaccio/sec/loginβ
Send the credentials as JSON, as the default theme does, or as a form.
{ "username": "jane", "password": "secret" }
A successful login answers 200 with the token to send as a bearer token from then on. The
response is never cached.
{ "username": "jane", "token": "<web token>" }
Wrong credentials answer 401 with WWW-Authenticate: Bearer, which keeps the browser from
opening its own basic authentication dialog, so your theme can show the error itself. The
endpoint has its own rate limit, userRateLimit.
PUT /-/verdaccio/sec/signupβ
Body { "name", "password", "email", "sessionId" }, where sessionId is a 36 character string
your theme generates. It answers { "username", "token" } like the login. A missing field or an
invalid sessionId is a 400. The same call is used by the
web login flow, where a 202 with Retry-After: 5 means the user has not
finished yet and the call should be repeated.
PUT /-/verdaccio/sec/reset_passwordβ
Needs a token. The body is { "password": { "old": "β¦", "new": "β¦" } } and the answer is
{ "ok": true }. Without a token it answers 401, and a new password that does not match
server.passwordValidationRegex is a 400.
Errorsβ
| Status | When |
|---|---|
401 | Wrong credentials on login, or no token on an endpoint that needs one. |
403 | The user is not allowed to access that package. |
404 | The package name is not valid, the package does not exist, or ?v= is not a version or tag. |
429 | The rate limit was exceeded. |
A minimal clientβ
const { base } = window.__VERDACCIO_BASENAME_UI_OPTIONS;
const api = (path: string, token?: string) =>
fetch(`${base}-/verdaccio/${path}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
// log in, then ask for what the user can see
const login = await fetch(`${base}-/verdaccio/sec/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: 'jane', password: 'secret' }),
});
const { token } = await login.json();
const packages = await (await api('data/packages', token)).json();
const detail = await (await api('data/sidebar/@verdaccio/ui?v=next', token)).json();
const readme = await (await api('data/package/readme/@verdaccio/ui', token)).text();
The default theme also uses two registry endpoints that are not part of the web API: the npm login
flow at /-/v1/login_cli, and /-/npm/v1/user to change the password.
You do not need to write these calls yourself: @verdaccio/ui-components
already implements them as hooks and providers.
Components UIβ
Building a user interface from scratch is a big effort, so the pieces the default theme is
made of are published on their own as @verdaccio/ui-components β React hooks, providers,
components and whole sections (sidebar, header, detail, home, footer).
See UI Components for installation, requirements and a worked example.