diff --git a/docs/content/2.guide/1.features.md b/docs/content/2.guide/1.features.md
index cef827452..81eee0564 100644
--- a/docs/content/2.guide/1.features.md
+++ b/docs/content/2.guide/1.features.md
@@ -92,19 +92,89 @@ Quick access to online development environments detected from package READMEs:
### Custom badges
-You can add custom npmx badges to your markdown files using the following syntax: `[](https://npmx.dev/package/YOUR_PACKAGE)`
+You can add custom npmx badges to your markdown files using the following syntax:
+`[](https://npmx.dev/package/YOUR_PACKAGE)`
-Do not forget to replace `YOUR_PACKAGE` with the actual package name.
+> [!IMPORTANT]
+> Make sure to replace `TYPE` with one of the options listed below and `YOUR_PACKAGE` with the actual package name (e.g., `vue`, `lodash`, or `@nuxt/kit`).
-Here are some examples:
+#### Available Badge Types
-```
-# Default
-[](https://npmx.dev/package/nuxt)
+- **version**: Shows the latest or specific version of the package. 
+- **license**: Displays the package license (e.g., MIT, Apache-2.0). 
+- **size**: Shows the install size (via Bundlephobia) or unpacked size. 
+- **downloads**: Displays monthly download statistics. 
+- **downloads-day**: Displays daily download statistics. 
+- **downloads-week**: Displays weekly download statistics. 
+- **downloads-month**: Alias for monthly download statistics. 
+- **downloads-year**: Displays yearly download statistics. 
+- **vulnerabilities**: Shows the number of vulnerabilities found via OSV.  / 
+- **dependencies**: Lists the total count of package dependencies. 
+- **created**: Displays the date the package was first published. 
+- **updated**: Displays the date of the most recent modification. 
+- **engines**: Shows the supported Node.js version range. 
+- **types**: Indicates if TypeScript types are included.  / 
+- **maintainers**: Displays the total count of package maintainers. 
+- **deprecated**: Shows if the package is active or deprecated.  / 
+- **quality**: NPMS.io quality score based on linting and tests. 
+- **popularity**: NPMS.io popularity score based on downloads and stars. 
+- **maintenance**: NPMS.io maintenance score based on activity. 
+- **score**: The overall NPMS.io combined score. 
+- **name**: Simple badge displaying the package name. 
+
+#### Examples
+
+```markdown
+# Version Badge
+
+[](https://npmx.dev/package/nuxt)
+
+# License Badge
+
+[](https://npmx.dev/package/vue)
+
+# Monthly Downloads
+
+[](https://npmx.dev/package/lodash)
+
+# Scoped Package (Install Size)
+
+[](https://npmx.dev/package/@nuxt/kit)
+
+# Specific Version
-# Organization packages
-[](https://npmx.dev/package/@nuxt/kit)
+[](https://npmx.dev/package/react)
-# Version-specific badges
-[](https://npmx.dev/package/nuxt/v/3.12.0)
+# Quality Score
+
+[](https://npmx.dev/package/pinia)
```
+
+#### Customization Parameters
+
+You can further customize your badges by appending query parameters to the badge URL.
+
+##### `color`
+
+Overrides the default strategy color. You can pass a standard hex code (with or without the `#` prefix).
+
+- **Default**: Depends on the badge type (e.g., version is blue, downloads are orange).
+- **Usage**: `?color=HEX_CODE`
+
+| Example | URL |
+| :------------- | :------------------------------------ |
+| **Hot Pink** | `.../badge/version/nuxt?color=ff69b4` |
+| **Pure Black** | `.../badge/version/nuxt?color=000000` |
+| **Brand Blue** | `.../badge/version/nuxt?color=3b82f6` |
+
+##### `name`
+
+When set to `true`, this parameter replaces the static category label (like "version" or "downloads/mo") with the actual name of the package. This is useful for brand-focused READMEs.
+
+- **Default**: `false`
+- **Usage**: `?name=true`
+
+| Type | Default Label | With `name=true` |
+| :------------ | :------------ | :--------------- | ------- | ------- |
+| **Version** | `version | 3.12.0` | `nuxt | 3.12.0` |
+| **Downloads** | `downloads/mo | 2M` | `lodash | 2M` |
diff --git a/server/api/registry/badge/[...pkg].get.ts b/server/api/registry/badge/[...pkg].get.ts
deleted file mode 100644
index 9bf5573b9..000000000
--- a/server/api/registry/badge/[...pkg].get.ts
+++ /dev/null
@@ -1,75 +0,0 @@
-import * as v from 'valibot'
-import { createError, getRouterParam, setHeader } from 'h3'
-import { PackageRouteParamsSchema } from '#shared/schemas/package'
-import { CACHE_MAX_AGE_ONE_HOUR } from '#shared/utils/constants'
-import { fetchLatestVersionWithFallback } from '#server/utils/npm'
-import { assertValidPackageName } from '#shared/utils/npm'
-import { handleApiError } from '#server/utils/error-handler'
-
-function measureTextWidth(text: string, charWidth = 6.2, paddingX = 6): number {
- return Math.max(40, Math.round(text.length * charWidth) + paddingX * 2)
-}
-
-export default defineCachedEventHandler(
- async event => {
- const pkgParamSegments = getRouterParam(event, 'pkg')?.split('/') ?? []
- if (pkgParamSegments.length === 0) {
- // TODO: throwing 404 rather than 400 as it's cacheable
- throw createError({ statusCode: 404, message: 'Package name is required.' })
- }
-
- const { rawPackageName, rawVersion } = parsePackageParams(pkgParamSegments)
-
- try {
- const { packageName, version: requestedVersion } = v.parse(PackageRouteParamsSchema, {
- packageName: rawPackageName,
- version: rawVersion,
- })
-
- assertValidPackageName(packageName)
-
- const label = `./ ${packageName}`
-
- const value =
- requestedVersion ?? (await fetchLatestVersionWithFallback(packageName)) ?? 'unknown'
-
- const leftWidth = measureTextWidth(label)
- const rightWidth = measureTextWidth(value)
- const totalWidth = leftWidth + rightWidth
- const height = 20
-
- const svg = `
-
- `.trim()
-
- setHeader(event, 'Content-Type', 'image/svg+xml')
-
- return svg
- } catch (error: unknown) {
- handleApiError(error, {
- statusCode: 502,
- message: 'Failed to generate npm badge.',
- })
- }
- },
- {
- maxAge: CACHE_MAX_AGE_ONE_HOUR,
- swr: true,
- getKey: event => {
- const pkg = getRouterParam(event, 'pkg') ?? ''
- return `badge:version:${pkg}`
- },
- },
-)
diff --git a/server/api/registry/badge/[type]/[...pkg].get.ts b/server/api/registry/badge/[type]/[...pkg].get.ts
new file mode 100644
index 000000000..8f6a56d45
--- /dev/null
+++ b/server/api/registry/badge/[type]/[...pkg].get.ts
@@ -0,0 +1,329 @@
+import * as v from 'valibot'
+import { createError, getRouterParam, getQuery, setHeader } from 'h3'
+import { PackageRouteParamsSchema } from '#shared/schemas/package'
+import { CACHE_MAX_AGE_ONE_HOUR, ERROR_NPM_FETCH_FAILED } from '#shared/utils/constants'
+import { fetchNpmPackage } from '#server/utils/npm'
+import { assertValidPackageName } from '#shared/utils/npm'
+import { handleApiError } from '#server/utils/error-handler'
+
+const NPM_DOWNLOADS_API = 'https://api.npmjs.org/downloads/point'
+const OSV_QUERY_API = 'https://api.osv.dev/v1/query'
+const BUNDLEPHOBIA_API = 'https://bundlephobia.com/api/size'
+const NPMS_API = 'https://api.npms.io/v2/package'
+
+const QUERY_SCHEMA = v.object({
+ color: v.optional(v.string()),
+ name: v.optional(v.string()),
+})
+
+const COLORS = {
+ blue: '#3b82f6',
+ green: '#22c55e',
+ purple: '#a855f7',
+ orange: '#f97316',
+ red: '#ef4444',
+ cyan: '#06b6d4',
+ slate: '#64748b',
+ yellow: '#eab308',
+ black: '#0a0a0a',
+ white: '#ffffff',
+}
+
+function measureTextWidth(text: string): number {
+ const charWidth = 7
+ const paddingX = 8
+ return Math.max(40, Math.round(text.length * charWidth) + paddingX * 2)
+}
+
+function formatBytes(bytes: number): string {
+ if (!+bytes) return '0 B'
+ const k = 1024
+ const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
+ const i = Math.floor(Math.log(bytes) / Math.log(k))
+ const value = parseFloat((bytes / Math.pow(k, i)).toFixed(2))
+ return `${value} ${sizes[i]}`
+}
+
+function formatNumber(num: number): string {
+ return new Intl.NumberFormat('en-US', { notation: 'compact', compactDisplay: 'short' }).format(
+ num,
+ )
+}
+
+function formatDate(dateString: string): string {
+ return new Date(dateString).toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ })
+}
+
+function getLatestVersion(pkgData: globalThis.Packument): string | undefined {
+ return pkgData['dist-tags']?.latest
+}
+
+async function fetchDownloads(
+ packageName: string,
+ period: 'last-day' | 'last-week' | 'last-month' | 'last-year',
+): Promise {
+ try {
+ const response = await fetch(`${NPM_DOWNLOADS_API}/${period}/${packageName}`)
+ const data = await response.json()
+ return data.downloads ?? 0
+ } catch {
+ return 0
+ }
+}
+
+async function fetchNpmsScore(packageName: string) {
+ try {
+ const response = await fetch(`${NPMS_API}/${encodeURIComponent(packageName)}`)
+ const data = await response.json()
+ return data.score
+ } catch {
+ return null
+ }
+}
+
+async function fetchVulnerabilities(packageName: string, version: string): Promise {
+ try {
+ const response = await fetch(OSV_QUERY_API, {
+ method: 'POST',
+ body: JSON.stringify({
+ version,
+ package: { name: packageName, ecosystem: 'npm' },
+ }),
+ })
+ const data = await response.json()
+ return data.vulns?.length ?? 0
+ } catch {
+ return 0
+ }
+}
+
+async function fetchInstallSize(packageName: string, version: string): Promise {
+ try {
+ const response = await fetch(`${BUNDLEPHOBIA_API}?package=${packageName}@${version}`)
+ const data = await response.json()
+ return data.size ?? null
+ } catch {
+ return null
+ }
+}
+
+const badgeStrategies = {
+ 'version': async (pkgData: globalThis.Packument, requestedVersion?: string) => {
+ const value = requestedVersion ?? getLatestVersion(pkgData) ?? 'unknown'
+ return { label: 'version', value, color: COLORS.blue }
+ },
+
+ 'license': async (pkgData: globalThis.Packument) => {
+ const latest = getLatestVersion(pkgData)
+ const versionData = latest ? pkgData.versions?.[latest] : undefined
+ const value = versionData?.license ?? 'unknown'
+ return { label: 'license', value, color: COLORS.green }
+ },
+
+ 'size': async (pkgData: globalThis.Packument) => {
+ const latest = getLatestVersion(pkgData)
+ const versionData = latest ? pkgData.versions?.[latest] : undefined
+ let bytes = versionData?.dist?.unpackedSize ?? 0
+ if (latest) {
+ const installSize = await fetchInstallSize(pkgData.name, latest)
+ if (installSize !== null) bytes = installSize
+ }
+ return { label: 'install size', value: formatBytes(bytes), color: COLORS.purple }
+ },
+
+ 'downloads': async (pkgData: globalThis.Packument) => {
+ const count = await fetchDownloads(pkgData.name, 'last-month')
+ return { label: 'downloads/mo', value: formatNumber(count), color: COLORS.orange }
+ },
+
+ 'downloads-day': async (pkgData: globalThis.Packument) => {
+ const count = await fetchDownloads(pkgData.name, 'last-day')
+ return { label: 'downloads/day', value: formatNumber(count), color: COLORS.orange }
+ },
+
+ 'downloads-week': async (pkgData: globalThis.Packument) => {
+ const count = await fetchDownloads(pkgData.name, 'last-week')
+ return { label: 'downloads/wk', value: formatNumber(count), color: COLORS.orange }
+ },
+
+ 'downloads-month': async (pkgData: globalThis.Packument) => {
+ const count = await fetchDownloads(pkgData.name, 'last-month')
+ return { label: 'downloads/mo', value: formatNumber(count), color: COLORS.orange }
+ },
+
+ 'downloads-year': async (pkgData: globalThis.Packument) => {
+ const count = await fetchDownloads(pkgData.name, 'last-year')
+ return { label: 'downloads/yr', value: formatNumber(count), color: COLORS.orange }
+ },
+
+ 'vulnerabilities': async (pkgData: globalThis.Packument) => {
+ const latest = getLatestVersion(pkgData)
+ const count = latest ? await fetchVulnerabilities(pkgData.name, latest) : 0
+ const isSafe = count === 0
+ const color = isSafe ? COLORS.green : COLORS.red
+ return { label: 'vulns', value: String(count), color }
+ },
+
+ 'dependencies': async (pkgData: globalThis.Packument) => {
+ const latest = getLatestVersion(pkgData)
+ const versionData = latest ? pkgData.versions?.[latest] : undefined
+ const count = Object.keys(versionData?.dependencies ?? {}).length
+ return { label: 'dependencies', value: String(count), color: COLORS.cyan }
+ },
+
+ 'created': async (pkgData: globalThis.Packument) => {
+ const dateStr = pkgData.time?.created ?? pkgData.time?.modified
+ return { label: 'created', value: formatDate(dateStr), color: COLORS.slate }
+ },
+
+ 'updated': async (pkgData: globalThis.Packument) => {
+ const dateStr = pkgData.time?.modified ?? pkgData.time?.created ?? new Date().toISOString()
+ return { label: 'updated', value: formatDate(dateStr), color: COLORS.slate }
+ },
+
+ 'engines': async (pkgData: globalThis.Packument) => {
+ const latest = getLatestVersion(pkgData)
+ const nodeVersion = (latest && pkgData.versions?.[latest]?.engines?.node) ?? '*'
+ return { label: 'node', value: nodeVersion, color: COLORS.yellow }
+ },
+
+ 'types': async (pkgData: globalThis.Packument) => {
+ const latest = getLatestVersion(pkgData)
+ const versionData = latest ? pkgData.versions?.[latest] : undefined
+ const hasTypes = !!(versionData?.types || versionData?.typings)
+ const value = hasTypes ? 'included' : 'missing'
+ const color = hasTypes ? COLORS.blue : COLORS.slate
+ return { label: 'types', value, color }
+ },
+
+ 'maintainers': async (pkgData: globalThis.Packument) => {
+ const count = pkgData.maintainers?.length ?? 0
+ return { label: 'maintainers', value: String(count), color: COLORS.cyan }
+ },
+
+ 'deprecated': async (pkgData: globalThis.Packument) => {
+ const latest = getLatestVersion(pkgData)
+ const isDeprecated = !!(latest && pkgData.versions?.[latest]?.deprecated)
+ return {
+ label: 'status',
+ value: isDeprecated ? 'deprecated' : 'active',
+ color: isDeprecated ? COLORS.red : COLORS.green,
+ }
+ },
+
+ 'quality': async (pkgData: globalThis.Packument) => {
+ const score = await fetchNpmsScore(pkgData.name)
+ const value = score ? `${Math.round(score.detail.quality * 100)}%` : 'unknown'
+ return { label: 'quality', value, color: COLORS.purple }
+ },
+
+ 'popularity': async (pkgData: globalThis.Packument) => {
+ const score = await fetchNpmsScore(pkgData.name)
+ const value = score ? `${Math.round(score.detail.popularity * 100)}%` : 'unknown'
+ return { label: 'popularity', value, color: COLORS.cyan }
+ },
+
+ 'maintenance': async (pkgData: globalThis.Packument) => {
+ const score = await fetchNpmsScore(pkgData.name)
+ const value = score ? `${Math.round(score.detail.maintenance * 100)}%` : 'unknown'
+ return { label: 'maintenance', value, color: COLORS.yellow }
+ },
+
+ 'score': async (pkgData: globalThis.Packument) => {
+ const score = await fetchNpmsScore(pkgData.name)
+ const value = score ? `${Math.round(score.final * 100)}%` : 'unknown'
+ return { label: 'score', value, color: COLORS.blue }
+ },
+}
+
+const BadgeTypeSchema = v.picklist(Object.keys(badgeStrategies) as [string, ...string[]])
+
+export default defineCachedEventHandler(
+ async event => {
+ const query = getQuery(event)
+ const typeParam = getRouterParam(event, 'type')
+ const pkgParamSegments = getRouterParam(event, 'pkg')?.split('/') ?? []
+
+ if (pkgParamSegments.length === 0) {
+ // TODO: throwing 404 rather than 400 as it's cacheable
+ throw createError({ statusCode: 404, message: 'Package name is required.' })
+ }
+
+ const { rawPackageName, rawVersion } = parsePackageParams(pkgParamSegments)
+
+ try {
+ const { packageName, version: requestedVersion } = v.parse(PackageRouteParamsSchema, {
+ packageName: rawPackageName,
+ version: rawVersion,
+ })
+
+ const queryParams = v.safeParse(QUERY_SCHEMA, query)
+ const userColor = queryParams.success ? queryParams.output.color : undefined
+ const showName = queryParams.success && queryParams.output.name === 'true'
+
+ const badgeTypeResult = v.safeParse(BadgeTypeSchema, typeParam)
+ const strategyKey = badgeTypeResult.success ? badgeTypeResult.output : 'version'
+ const strategy = badgeStrategies[strategyKey as keyof typeof badgeStrategies]
+
+ assertValidPackageName(packageName)
+
+ const pkgData = await fetchNpmPackage(packageName)
+ const strategyResult = await strategy(pkgData, requestedVersion)
+
+ const finalLabel = showName ? packageName : strategyResult.label
+ const finalValue = strategyResult.value
+
+ const rawColor = userColor ?? strategyResult.color
+ const finalColor = rawColor?.startsWith('#') ? rawColor : `#${rawColor}`
+
+ const leftWidth = measureTextWidth(finalLabel)
+ const rightWidth = measureTextWidth(finalValue)
+ const totalWidth = leftWidth + rightWidth
+ const height = 20
+
+ const svg = `
+
+ `.trim()
+
+ setHeader(event, 'Content-Type', 'image/svg+xml')
+ setHeader(
+ event,
+ 'Cache-Control',
+ `public, max-age=${CACHE_MAX_AGE_ONE_HOUR}, s-maxage=${CACHE_MAX_AGE_ONE_HOUR}`,
+ )
+
+ return svg
+ } catch (error: unknown) {
+ handleApiError(error, {
+ statusCode: 502,
+ message: ERROR_NPM_FETCH_FAILED,
+ })
+ }
+ },
+ {
+ maxAge: CACHE_MAX_AGE_ONE_HOUR,
+ swr: true,
+ getKey: event => {
+ const type = getRouterParam(event, 'type') ?? 'version'
+ const pkg = getRouterParam(event, 'pkg') ?? ''
+ const query = getQuery(event)
+ return `badge:${type}:${pkg}:${JSON.stringify(query)}`
+ },
+ },
+)
diff --git a/test/e2e/badge.spec.ts b/test/e2e/badge.spec.ts
index 65d354b1b..9254ec910 100644
--- a/test/e2e/badge.spec.ts
+++ b/test/e2e/badge.spec.ts
@@ -12,34 +12,115 @@ async function fetchBadge(page: { request: { get: (url: string) => Promise
}
test.describe('badge API', () => {
- test('unscoped package badge renders SVG', async ({ page, baseURL }) => {
- const url = toLocalUrl(baseURL, '/api/registry/badge/nuxt')
- const { response, body } = await fetchBadge(page, url)
-
- expect(response.status()).toBe(200)
- expect(response.headers()['content-type']).toContain('image/svg+xml')
- expect(body).toContain('