@@ -192,6 +189,16 @@ initializeModules()
+
+
+
{
- const { version, category } = await getValidatedQuery(event, z.object({
- version: z.enum(['2', '2-bridge', '3', 'all']).default('3'),
- category: z.string().optional()
- }).parse)
+ const { version, category } = await getValidatedQuery(event, query =>
+ normalizeModulesQuery(query as Record)
+ )
console.log(`Fetching v${version} modules...${category ? ` for category: ${category}` : ''}`)
let modules = await fetchModules(event) || []
if (version !== 'all') {
- const major = (version === '2-bridge' ? '2' : version) satisfies '2' | '3'
- const testableVersion = `${major}.999.999`
+ const major = (version === '2-bridge' ? 2 : Number(version)) as 2 | 3 | 4
// Filter out modules by compatibility
modules = modules.filter((module) => {
@@ -20,7 +18,7 @@ export default defineCachedEventHandler(async (event) => {
if (version === '2-bridge' && !module.compatibility.requires?.bridge) {
return false
}
- return satisfies(testableVersion, module.compatibility.nuxt)
+ return moduleSupportsNuxt(module.compatibility.nuxt, major)
})
}
@@ -101,8 +99,7 @@ export default defineCachedEventHandler(async (event) => {
name: 'modules',
swr: true,
getKey(event) {
- const query = getQuery(event)
- return `${query?.version || '3'}-${query?.category || 'all'}`
+ return modulesCacheKey(getQuery(event) as Record)
},
maxAge: 60 * 60 // 1 hour
})
diff --git a/server/mcp/tools/modules/get-module.ts b/server/mcp/tools/modules/get-module.ts
index 59ef7ea33..889a99cc2 100644
--- a/server/mcp/tools/modules/get-module.ts
+++ b/server/mcp/tools/modules/get-module.ts
@@ -1,5 +1,6 @@
import { z } from 'zod'
import type { Module } from '#shared/types'
+import { getModuleNuxtMajors } from '#shared/utils/modules'
export default defineMcpTool({
description: `Retrieves complete details about a specific Nuxt module including README, compatibility, maintainers, and stats.
@@ -8,9 +9,9 @@ WHEN TO USE: Use this tool when you know the EXACT module identifier. Common sce
- User asks for a specific module: "Get details about @nuxt/ui"
- User mentions a known module: "Show me nuxt-icon module"
- You found a relevant module from list_modules and want full details
-- You need to check Nuxt 4 compatibility for a specific module
+- You need to check Nuxt 4 compatibility for a specific module (see compatibility.nuxt and nuxtMajors)
-WHEN NOT TO USE: If you don't know the exact module identifier and need to search/discover modules, use list_modules first.
+WHEN NOT TO USE: If you don't know the exact module identifier and need to search/discover modules, use list_modules first (pass version "4" to filter the catalog).
PARAMETER: slug (required) - The unique module identifier
EXAMPLES:
@@ -63,6 +64,7 @@ EXAMPLES:
category: module.category,
type: module.type,
compatibility: module.compatibility,
+ nuxtMajors: getModuleNuxtMajors(module.compatibility?.nuxt),
stats: module.stats ? { downloads: module.stats.downloads, stars: module.stats.stars } : undefined,
readme,
url: `https://nuxt.com/modules/${module.name}`
diff --git a/server/mcp/tools/modules/list-modules.ts b/server/mcp/tools/modules/list-modules.ts
index 573ea7546..c4057e2aa 100644
--- a/server/mcp/tools/modules/list-modules.ts
+++ b/server/mcp/tools/modules/list-modules.ts
@@ -1,5 +1,6 @@
import { z } from 'zod'
import type { Module, Stats } from '#shared/types'
+import { getModuleNuxtMajors, moduleSupportsNuxt } from '#shared/utils/modules'
export default defineMcpTool({
description: `Lists all available Nuxt modules with optional filtering and sorting capabilities.
@@ -9,20 +10,23 @@ WHEN TO USE: Use this tool when you need to DISCOVER or SEARCH for modules. Comm
- "What UI libraries are available?" - filter by category
- "Show me popular image optimization modules" - filter + sort by downloads
- "Find a module for X feature" - general exploration
+- "Modules compatible with Nuxt 4" - set version to "4"
PARAMETERS:
- search: Filter by name, description, or npm package name
- category: Filter by exact category name (e.g., "Security", "UI", "Database", "CMS", "SEO") — use search for keyword discovery, not category
+- version: Filter by declared Nuxt major via compatibility.nuxt ("3", "4", or "all"). Default "3" matches the modules catalog on nuxt.com.
- sort: Order by downloads, stars, publishedAt, or createdAt
- order: asc or desc
- includeStats: Include full per-module stats (downloads, stars, publishedAt, createdAt, etc.). Defaults to true when sort is publishedAt or createdAt.
WHEN NOT TO USE: If you already know the exact module slug (e.g., "@nuxt/ui"), use get_module directly.
-OUTPUT: Returns list of modules with name, description, category, and either downloads/stars or full stats. Use get_module for complete details including README and compatibility.`,
+OUTPUT: Returns list of modules with name, description, category, nuxtMajors (3 and/or 4 from compatibility.nuxt), and either downloads/stars or full stats. Use get_module for complete details including README and compatibility.`,
inputSchema: {
search: z.string().optional().describe('Search term to filter modules by name, description, or npm package name'),
category: z.string().optional().describe('Filter by exact category name (e.g., "Security", "UI", "Database", "CMS", "SEO"). Auth modules use category "Security", not "auth". Prefer search for keyword discovery.'),
+ version: z.enum(['3', '4', 'all']).optional().default('3').describe('Filter by Nuxt major declared in compatibility.nuxt. Use "all" to skip major filtering.'),
sort: z.enum(['downloads', 'stars', 'publishedAt', 'createdAt']).optional().default('downloads').describe('Sort modules by downloads, stars, published date, or created date'),
order: z.enum(['asc', 'desc']).optional().default('desc').describe('Sort order (ascending or descending)'),
includeStats: z.boolean().optional().describe('Include full per-module stats. Defaults to true when sort is publishedAt or createdAt.')
@@ -34,19 +38,25 @@ OUTPUT: Returns list of modules with name, description, category, and either dow
inputExamples: [
{ search: 'auth' },
{ category: 'ui', sort: 'downloads' },
- { search: 'image', sort: 'stars', order: 'desc' }
+ { search: 'image', sort: 'stars', order: 'desc' },
+ { version: '4', category: 'UI' }
],
cache: {
maxAge: '1h',
- getKey: (args: { search?: string, category?: string, sort?: string, order?: string, includeStats?: boolean }) =>
- `search=${args.search ?? ''}|category=${args.category ?? ''}|sort=${args.sort ?? 'downloads'}|order=${args.order ?? 'desc'}|stats=${args.includeStats ?? (args.sort === 'publishedAt' || args.sort === 'createdAt')}`
+ getKey: (args: { search?: string, category?: string, version?: string, sort?: string, order?: string, includeStats?: boolean }) =>
+ `search=${args.search ?? ''}|category=${args.category ?? ''}|version=${args.version ?? '3'}|sort=${args.sort ?? 'downloads'}|order=${args.order ?? 'desc'}|stats=${args.includeStats ?? (args.sort === 'publishedAt' || args.sort === 'createdAt')}`
},
- async handler({ search, category, sort = 'downloads', order = 'desc', includeStats }) {
+ async handler({ search, category, version = '3', sort = 'downloads', order = 'desc', includeStats }) {
const withStats = includeStats ?? (sort === 'publishedAt' || sort === 'createdAt')
const response = await $fetch<{ modules: Module[], stats: Stats }>('https://api.nuxt.com/modules')
let modules = response.modules || []
+ if (version !== 'all') {
+ const major = Number(version) as 3 | 4
+ modules = modules.filter(module => moduleSupportsNuxt(module.compatibility?.nuxt, major))
+ }
+
if (category) {
modules = modules.filter(module => module.category === category)
}
@@ -114,6 +124,7 @@ OUTPUT: Returns list of modules with name, description, category, and either dow
website: module.website,
learn_more: module.learn_more,
category: module.category,
+ nuxtMajors: getModuleNuxtMajors(module.compatibility?.nuxt),
url: `https://nuxt.com/modules/${module.name}`
}
diff --git a/server/routes/raw/modules.md.get.ts b/server/routes/raw/modules.md.get.ts
index 4e00e705f..a80b164a2 100644
--- a/server/routes/raw/modules.md.get.ts
+++ b/server/routes/raw/modules.md.get.ts
@@ -1,3 +1,5 @@
+import { getModuleNuxtMajors } from '#shared/utils/modules'
+
export default defineCachedEventHandler(async (event) => {
const domain = getSiteUrl(event)
const modules = await fetchModules(event) || []
@@ -6,6 +8,9 @@ export default defineCachedEventHandler(async (event) => {
'# Nuxt Modules',
'',
`> ${modules.length}+ modules to supercharge your [Nuxt](https://nuxt.com) project.`,
+ '',
+ 'On the [modules catalog](https://nuxt.com/modules), filter by Nuxt major with `?version=3`, `?version=4`, or `?version=all` (default is Nuxt 3).',
+ 'Each card shows Nuxt 3 / Nuxt 4 badges derived from the module\'s `compatibility.nuxt` semver range in the [nuxt/modules](https://github.com/nuxt/modules) registry.',
''
]
@@ -19,6 +24,7 @@ export default defineCachedEventHandler(async (event) => {
for (const [category, mods] of categories) {
lines.push(`## ${category}`, '')
for (const mod of mods) {
+ const majors = getModuleNuxtMajors(mod.compatibility?.nuxt)
const links = [
mod.website ? `[Docs](${mod.website})` : '',
mod.repo ? `[GitHub](https://github.com/${mod.repo})` : '',
@@ -27,6 +33,9 @@ export default defineCachedEventHandler(async (event) => {
lines.push(`### ${mod.npm}`, '')
lines.push(mod.description, '')
+ if (majors.length) {
+ lines.push(`Nuxt: ${majors.map(major => String(major)).join(', ')}`, '')
+ }
lines.push(`Install: \`npx nuxt@latest module add ${mod.name}\``, '')
lines.push(links, '')
}
diff --git a/shared/utils/modules-query.ts b/shared/utils/modules-query.ts
new file mode 100644
index 000000000..2736feecb
--- /dev/null
+++ b/shared/utils/modules-query.ts
@@ -0,0 +1,44 @@
+import { z } from 'zod'
+
+export const modulesVersionValues = ['2', '2-bridge', '3', '4', 'all'] as const
+
+export const modulesQuerySchema = z.object({
+ version: z.enum(modulesVersionValues).default('3'),
+ category: z.string().optional()
+})
+
+export type ModulesQuery = z.infer
+
+/**
+ * Normalize h3 query values before Zod parse.
+ * Numeric-looking params (e.g. version=4) may arrive as numbers.
+ */
+export function normalizeModulesQuery(raw: Record | undefined): ModulesQuery {
+ const versionRaw = raw?.version
+ const categoryRaw = raw?.category
+
+ return modulesQuerySchema.parse({
+ version: versionRaw == null || versionRaw === ''
+ ? undefined
+ : String(Array.isArray(versionRaw) ? versionRaw[0] : versionRaw),
+ category: typeof categoryRaw === 'string'
+ ? categoryRaw
+ : Array.isArray(categoryRaw) && typeof categoryRaw[0] === 'string'
+ ? categoryRaw[0]
+ : undefined
+ })
+}
+
+export function modulesCacheKey(raw: Record | undefined): string {
+ const versionRaw = raw?.version
+ const categoryRaw = raw?.category
+ const version = versionRaw == null || versionRaw === ''
+ ? '3'
+ : String(Array.isArray(versionRaw) ? versionRaw[0] : versionRaw)
+ const category = typeof categoryRaw === 'string'
+ ? categoryRaw
+ : Array.isArray(categoryRaw) && typeof categoryRaw[0] === 'string'
+ ? categoryRaw[0]
+ : 'all'
+ return `${version}-${category || 'all'}`
+}
diff --git a/shared/utils/modules.ts b/shared/utils/modules.ts
new file mode 100644
index 000000000..8a0a73e08
--- /dev/null
+++ b/shared/utils/modules.ts
@@ -0,0 +1,32 @@
+import { satisfies } from 'semver'
+
+export type NuxtMajor = 2 | 3 | 4
+export type ModuleNuxtBadgeMajor = 3 | 4
+
+/**
+ * Whether a module's `compatibility.nuxt` semver range includes the given major.
+ * Uses the same probe version pattern as `/api/v1/modules` (`${major}.999.999`).
+ */
+export function moduleSupportsNuxt(range: string | undefined, major: NuxtMajor): boolean {
+ if (!range) {
+ return false
+ }
+
+ try {
+ return satisfies(`${major}.999.999`, range)
+ } catch {
+ return false
+ }
+}
+
+/** Majors shown as badges on the modules UI (Nuxt 3 / Nuxt 4). */
+export function getModuleNuxtMajors(range: string | undefined): ModuleNuxtBadgeMajor[] {
+ const majors: ModuleNuxtBadgeMajor[] = []
+ if (moduleSupportsNuxt(range, 3)) {
+ majors.push(3)
+ }
+ if (moduleSupportsNuxt(range, 4)) {
+ majors.push(4)
+ }
+ return majors
+}
diff --git a/test/browser/modules.spec.ts b/test/browser/modules.spec.ts
index a2ece3718..8405cf73f 100644
--- a/test/browser/modules.spec.ts
+++ b/test/browser/modules.spec.ts
@@ -55,6 +55,21 @@ test.describe('Modules Page', () => {
expect(await moduleLinks.count()).toBeLessThanOrEqual(count)
})
+ test('filters modules by Nuxt version', async ({ page, goto }) => {
+ await goto('/modules')
+
+ await page.goto('/modules?version=4')
+ await expect(page).toHaveURL(/version=4/)
+ await expect(page.getByText('Nuxt 4').first()).toBeVisible()
+
+ const moduleLinks = page.locator('a[href^="/modules/"]')
+ await expect.poll(async () => moduleLinks.count()).toBeGreaterThan(0)
+
+ await page.goto('/modules?version=all')
+ await expect(page).toHaveURL(/version=all/)
+ await expect(page.getByText('All').first()).toBeVisible()
+ })
+
// TODO: needs to be fixed in nuxt/ui
test.skip('navigates to module detail page', async ({ page, goto }) => {
await goto('/modules')
diff --git a/test/unit/modules-compatibility.spec.ts b/test/unit/modules-compatibility.spec.ts
new file mode 100644
index 000000000..66e0671c9
--- /dev/null
+++ b/test/unit/modules-compatibility.spec.ts
@@ -0,0 +1,62 @@
+import { describe, expect, it } from 'vitest'
+import { getModuleNuxtMajors, moduleSupportsNuxt } from '../../shared/utils/modules'
+
+/** Deterministic stand-in for modules.json compatibility cases (no network). */
+const registryFixture = [
+ { name: 'only-nuxt3', compatibility: { nuxt: '^3.0.0' } },
+ { name: 'only-nuxt4', compatibility: { nuxt: '^4.0.0' } },
+ { name: 'nuxt3-and-4', compatibility: { nuxt: '^3.15.0 || ^4.0.0' } },
+ { name: 'nuxt-gte-3', compatibility: { nuxt: '>=3.0.0' } },
+ { name: 'nuxt2-only', compatibility: { nuxt: '^2.0.0' } },
+ { name: 'missing-range', compatibility: {} },
+ // Pad so the suite still asserts a registry-sized catalog (>100 modules).
+ ...Array.from({ length: 100 }, (_, i) => ({
+ name: `fixture-module-${i}`,
+ compatibility: { nuxt: i % 2 === 0 ? '^3.0.0 || ^4.0.0' : '^3.0.0' }
+ }))
+]
+
+describe('moduleSupportsNuxt against modules registry fixture', () => {
+ it('finds Nuxt 4 compatible modules without network access', () => {
+ const nuxt4 = registryFixture.filter(module =>
+ moduleSupportsNuxt(module.compatibility?.nuxt, 4)
+ )
+ expect(registryFixture.length).toBeGreaterThan(100)
+ expect(nuxt4.length).toBeGreaterThan(50)
+ })
+})
+
+describe('moduleSupportsNuxt', () => {
+ it('matches caret ranges for a single major', () => {
+ expect(moduleSupportsNuxt('^3.0.0', 3)).toBe(true)
+ expect(moduleSupportsNuxt('^3.0.0', 4)).toBe(false)
+ expect(moduleSupportsNuxt('^4.0.0', 3)).toBe(false)
+ expect(moduleSupportsNuxt('^4.0.0', 4)).toBe(true)
+ })
+
+ it('treats >=3.0.0 as compatible with Nuxt 3 and 4', () => {
+ expect(moduleSupportsNuxt('>=3.0.0', 3)).toBe(true)
+ expect(moduleSupportsNuxt('>=3.0.0', 4)).toBe(true)
+ })
+
+ it('handles union ranges', () => {
+ const range = '^3.15.0 || ^4.0.0'
+ expect(moduleSupportsNuxt(range, 3)).toBe(true)
+ expect(moduleSupportsNuxt(range, 4)).toBe(true)
+ })
+
+ it('returns false for empty or invalid ranges', () => {
+ expect(moduleSupportsNuxt(undefined, 3)).toBe(false)
+ expect(moduleSupportsNuxt('', 3)).toBe(false)
+ expect(moduleSupportsNuxt('not-a-range', 3)).toBe(false)
+ })
+})
+
+describe('getModuleNuxtMajors', () => {
+ it('returns badge majors for common ranges', () => {
+ expect(getModuleNuxtMajors('^3.0.0')).toEqual([3])
+ expect(getModuleNuxtMajors('^4.0.0')).toEqual([4])
+ expect(getModuleNuxtMajors('>=3.0.0')).toEqual([3, 4])
+ expect(getModuleNuxtMajors('^3.15.0 || ^4.0.0')).toEqual([3, 4])
+ })
+})
diff --git a/test/unit/modules-query.spec.ts b/test/unit/modules-query.spec.ts
new file mode 100644
index 000000000..84da26128
--- /dev/null
+++ b/test/unit/modules-query.spec.ts
@@ -0,0 +1,28 @@
+import { describe, expect, it } from 'vitest'
+import { modulesCacheKey, normalizeModulesQuery } from '../../shared/utils/modules-query'
+
+describe('normalizeModulesQuery', () => {
+ it('accepts string version values', () => {
+ expect(normalizeModulesQuery({ version: '4' }).version).toBe('4')
+ expect(normalizeModulesQuery({ version: '3' }).version).toBe('3')
+ expect(normalizeModulesQuery({ version: 'all' }).version).toBe('all')
+ })
+
+ it('coerces numeric version values from h3 query parsing', () => {
+ expect(normalizeModulesQuery({ version: 4 }).version).toBe('4')
+ expect(normalizeModulesQuery({ version: 3 }).version).toBe('3')
+ })
+
+ it('defaults missing version to 3', () => {
+ expect(normalizeModulesQuery({}).version).toBe('3')
+ expect(normalizeModulesQuery({ version: '' }).version).toBe('3')
+ expect(normalizeModulesQuery({ version: null }).version).toBe('3')
+ })
+})
+
+describe('modulesCacheKey', () => {
+ it('stringifies numeric version in the cache key', () => {
+ expect(modulesCacheKey({ version: 4 })).toBe('4-all')
+ expect(modulesCacheKey({ version: '4', category: 'UI' })).toBe('4-UI')
+ })
+})