diff --git a/apps/v4/content/docs/changelog/2026-07-dynamic-search.mdx b/apps/v4/content/docs/changelog/2026-07-dynamic-search.mdx
new file mode 100644
index 00000000000..283d705f2e3
--- /dev/null
+++ b/apps/v4/content/docs/changelog/2026-07-dynamic-search.mdx
@@ -0,0 +1,44 @@
+---
+title: July 2026 - Dynamic Search
+description: Registries can now handle search server-side.
+date: 2026-07-31
+---
+
+**Registries can now handle search server-side.**
+
+When you run `shadcn search`, the CLI forwards the search parameters to your
+registry as query params:
+
+```txt
+GET /r/registry.json?q=button&limit=50&offset=0
+```
+
+Return the matching items with a `pagination` object and the CLI uses your
+results as-is. This makes search fast for large registries: no more downloading
+the full catalog to search it.
+
+```json title="registry.json?q=button&limit=1"
+{
+ "name": "acme",
+ "homepage": "https://acme.com",
+ "items": [
+ {
+ "name": "button",
+ "type": "registry:ui",
+ "description": "A button component."
+ }
+ ],
+ "pagination": {
+ "total": 12,
+ "offset": 0,
+ "limit": 1,
+ "hasMore": true
+ }
+}
+```
+
+Dynamic search is opt-in. Static registries ignore the query params and keep
+working without any changes.
+
+See the [Dynamic Search](/docs/registry/dynamic-search) docs for the full
+guide.
diff --git a/apps/v4/content/docs/registry/dynamic-search.mdx b/apps/v4/content/docs/registry/dynamic-search.mdx
new file mode 100644
index 00000000000..8507395e919
--- /dev/null
+++ b/apps/v4/content/docs/registry/dynamic-search.mdx
@@ -0,0 +1,199 @@
+---
+title: Dynamic Search
+description: Implement server-side search for large registries.
+---
+
+By default, `shadcn search` fetches your entire `registry.json` and filters
+items locally. This works well for most registries and requires nothing more
+than a static file.
+
+For large registries with thousands of items, you can implement search on your
+registry server instead. The CLI forwards the search parameters to your
+registry and your server returns only the matching items.
+
+Dynamic search is opt-in and fully backwards compatible. Static registries
+keep working without any changes.
+
+## How It Works
+
+When you run `shadcn search`, the CLI appends the search parameters to the
+catalog request:
+
+```bash
+npx shadcn@latest search @acme --query button --limit 50
+```
+
+```txt
+GET https://acme.com/r/registry.json?q=button&limit=50&offset=0
+```
+
+What happens next depends on your registry:
+
+- **Static registries** ignore the query parameters and return the full
+ `registry.json`. The CLI filters the items locally. This is the default
+ behavior and requires no changes.
+- **Dynamic registries** filter the items server-side and return the matching
+ items along with a `pagination` object. When the CLI sees `pagination` in
+ the response, it trusts the results as pre-filtered and skips local
+ filtering.
+
+The presence of `pagination` in the response is what tells the CLI your
+registry handles search server-side. There is no configuration or capability
+negotiation required.
+
+## Query Parameters
+
+The CLI sends the following query parameters with every search request:
+
+| Parameter | Description |
+| --------- | -------------------------------------------------------------- |
+| `q` | The search query string. |
+| `type` | Comma-separated item types, e.g. `registry:ui,registry:block`. |
+| `limit` | Maximum number of items to return. |
+| `offset` | Number of items to skip. |
+
+All parameters are optional. A request without `q` or `type` should return
+all items, paginated.
+
+## Response Format
+
+Return your regular `registry.json` shape with an additional `pagination`
+object:
+
+```json title="registry.json?q=button&limit=2"
+{
+ "name": "acme",
+ "homepage": "https://acme.com",
+ "items": [
+ {
+ "name": "button",
+ "type": "registry:ui",
+ "description": "A button component."
+ },
+ {
+ "name": "icon-button",
+ "type": "registry:ui",
+ "description": "A button component with an icon."
+ }
+ ],
+ "pagination": {
+ "total": 12,
+ "offset": 0,
+ "limit": 2,
+ "hasMore": true
+ }
+}
+```
+
+Search results only need `name`, `type` and `description` for each item. You
+do not need to include `files`, `dependencies` or other item properties. The
+CLI fetches the full item definition when the user runs `shadcn add`.
+
+### pagination
+
+| Property | Type | Description |
+| --------- | --------- | -------------------------------------------------- |
+| `total` | `number` | Total number of items matching the query. |
+| `offset` | `number` | Number of items skipped. |
+| `limit` | `number` | Maximum number of items in this response. |
+| `hasMore` | `boolean` | Whether more items are available beyond this page. |
+
+## Server Implementation
+
+Here's an example using a Next.js route handler:
+
+```typescript title="app/r/registry.json/route.ts"
+import { NextRequest, NextResponse } from "next/server"
+
+export async function GET(request: NextRequest) {
+ const { searchParams } = request.nextUrl
+
+ const query = searchParams.get("q")
+ const types = searchParams.get("type")?.split(",")
+ const limit = Number(searchParams.get("limit") ?? 100)
+ const offset = Number(searchParams.get("offset") ?? 0)
+
+ // Filter items using your database or search index.
+ const { items, total } = await searchItems({ query, types, limit, offset })
+
+ return NextResponse.json({
+ name: "acme",
+ homepage: "https://acme.com",
+ items,
+ pagination: {
+ total,
+ offset,
+ limit,
+ hasMore: offset + limit < total,
+ },
+ })
+}
+```
+
+You can back `searchItems` with anything: a database query, a full-text
+search index or an external search service.
+
+## Multiple Registries
+
+When searching a single registry, the CLI forwards all parameters and uses
+your `pagination` response as-is.
+
+When searching multiple registries at once, e.g. `shadcn search @acme @lib`,
+a global `offset` cannot be split across registries. The CLI forwards `q` and
+`type` to each registry along with a `limit` large enough to fill the
+requested page (`offset + limit`), then merges and paginates the combined
+results locally. Server-side filtering still applies, so each registry only
+returns matching items.
+
+If your registry caps the number of items per response below the requested
+`limit`, the CLI treats it as exhausted for deeper pages. Honor the requested
+`limit` where possible so all your matches stay reachable through paging.
+
+## Authentication
+
+Dynamic search works with all [authentication](/docs/registry/authentication)
+patterns. The CLI sends the configured headers and params with the search
+request, so you can scope search results to the authenticated user:
+
+```typescript title="app/r/registry.json/route.ts"
+export async function GET(request: NextRequest) {
+ const token = request.headers.get("authorization")?.replace("Bearer ", "")
+ const team = await getTeamFromToken(token)
+
+ // Only search items this team can access.
+ const { items, total } = await searchItems({
+ query: request.nextUrl.searchParams.get("q"),
+ team,
+ })
+
+ // ...
+}
+```
+
+## Backwards Compatibility
+
+- **Static registries** require no changes. Query parameters on a static file
+ are ignored by the file server and the CLI falls back to local filtering.
+- **Older CLI versions** fetch the catalog without query parameters and
+ ignore the `pagination` field. Your registry should return a sensible
+ default response for requests without parameters, e.g. the first page of
+ items.
+- **Ranking** is up to your server. When your registry returns pre-filtered
+ results, the CLI preserves your order instead of re-ranking locally.
+
+## Testing
+
+Test your dynamic registry with `curl`:
+
+```bash
+curl "https://acme.com/r/registry.json?q=button&limit=10"
+```
+
+Then verify with the CLI:
+
+```bash
+npx shadcn@latest search @acme --query button
+```
+
+To confirm server-side search is active, check that the response includes the
+`pagination` object and only the matching items.
diff --git a/apps/v4/content/docs/registry/meta.json b/apps/v4/content/docs/registry/meta.json
index 61a2cac9653..965ff964244 100644
--- a/apps/v4/content/docs/registry/meta.json
+++ b/apps/v4/content/docs/registry/meta.json
@@ -8,6 +8,7 @@
"examples",
"namespace",
"authentication",
+ "dynamic-search",
"mcp",
"open-in-v0",
"api-reference",
diff --git a/apps/v4/lib/docs.ts b/apps/v4/lib/docs.ts
index cf9b44393df..64d617b3dbb 100644
--- a/apps/v4/lib/docs.ts
+++ b/apps/v4/lib/docs.ts
@@ -16,6 +16,7 @@ export const PAGES_NEW = [
"/docs/helpers/ai-sdk",
"/docs/helpers/tanstack-ai",
"/docs/react/message-scroller",
+ "/docs/registry/dynamic-search",
]
export const PAGES_UPDATED = []
diff --git a/apps/v4/package.json b/apps/v4/package.json
index e6c172b9ba2..48ed248e9e7 100644
--- a/apps/v4/package.json
+++ b/apps/v4/package.json
@@ -90,7 +90,7 @@
"rehype-pretty-code": "^0.14.1",
"rimraf": "^6.0.1",
"server-only": "^0.0.1",
- "shadcn": "4.16.0",
+ "shadcn": "4.16.1",
"shiki": "^3.23.0",
"sonner": "^2.0.0",
"streamdown": "^2.5.0",
diff --git a/apps/v4/public/schema/registry.json b/apps/v4/public/schema/registry.json
index 3201ec6c2f0..7243e0786ec 100644
--- a/apps/v4/public/schema/registry.json
+++ b/apps/v4/public/schema/registry.json
@@ -27,6 +27,29 @@
"items": {
"$ref": "https://ui.shadcn.com/schema/registry-item.json"
}
+ },
+ "pagination": {
+ "type": "object",
+ "description": "Pagination metadata returned by registries that implement dynamic search. Its presence on a catalog response signals that the items are already filtered and paginated server-side.",
+ "properties": {
+ "total": {
+ "type": "number",
+ "description": "Total number of items matching the query."
+ },
+ "offset": {
+ "type": "number",
+ "description": "Number of items skipped."
+ },
+ "limit": {
+ "type": "number",
+ "description": "Maximum number of items in this response."
+ },
+ "hasMore": {
+ "type": "boolean",
+ "description": "Whether more items are available beyond this page."
+ }
+ },
+ "required": ["total", "offset", "limit", "hasMore"]
}
},
"anyOf": [{ "required": ["items"] }, { "required": ["include"] }]
diff --git a/apps/v4/registry/directory.json b/apps/v4/registry/directory.json
index ed7ad60036f..8f59dbcd21f 100644
--- a/apps/v4/registry/directory.json
+++ b/apps/v4/registry/directory.json
@@ -1841,5 +1841,12 @@
"url": "https://ai2.design/r/{name}.json",
"description": "Agent-native design system for the shadcn CLI: 51 base components with a full variant, tone and size matrix on an OKLCH token layer, plus 307 styled variations. MIT.",
"logo": ""
+ },
+ {
+ "name": "@whiskeyjack",
+ "homepage": "https://whiskeyjack.net",
+ "url": "https://whiskeyjack.net/r/{name}.json",
+ "description": "A Tauri-first design system: thumb-first components with Metro-style pivot navigation, frosted bottom nav, and tap-again confirmations, on a CSS-variable token pipeline. Hardened across nine shipping apps before extraction.",
+ "logo": ""
}
]
diff --git a/packages/shadcn/CHANGELOG.md b/packages/shadcn/CHANGELOG.md
index ceebf8c9c88..090cc314b72 100644
--- a/packages/shadcn/CHANGELOG.md
+++ b/packages/shadcn/CHANGELOG.md
@@ -1,5 +1,13 @@
# shadcn
+## 4.16.1
+
+### Patch Changes
+
+- [#11322](https://github.com/shadcn-ui/ui/pull/11322) [`bfa1b5e9a69a155b2f590523d50fda810bde1a9a`](https://github.com/shadcn-ui/ui/commit/bfa1b5e9a69a155b2f590523d50fda810bde1a9a) Thanks [@AndrewBarba](https://github.com/AndrewBarba)! - fix `shadcn build` failing with ENOENT when registry item names contain path segments (e.g. `extension/foo`) by creating nested output directories before writing
+
+- [#11352](https://github.com/shadcn-ui/ui/pull/11352) [`5ca53ca7c7dea390e0e78091ff7c54adc48c773a`](https://github.com/shadcn-ui/ui/commit/5ca53ca7c7dea390e0e78091ff7c54adc48c773a) Thanks [@shadcn](https://github.com/shadcn)! - forward search params to registries for server-side dynamic search
+
## 4.16.0
### Minor Changes
diff --git a/packages/shadcn/package.json b/packages/shadcn/package.json
index bbf19f1d49b..48d3792a8f7 100644
--- a/packages/shadcn/package.json
+++ b/packages/shadcn/package.json
@@ -1,6 +1,6 @@
{
"name": "shadcn",
- "version": "4.16.0",
+ "version": "4.16.1",
"description": "Add components to your apps.",
"publishConfig": {
"access": "public"
diff --git a/packages/shadcn/src/commands/build.test.ts b/packages/shadcn/src/commands/build.test.ts
index 5b63b61da2e..20013f284c0 100644
--- a/packages/shadcn/src/commands/build.test.ts
+++ b/packages/shadcn/src/commands/build.test.ts
@@ -83,6 +83,48 @@ describe("build command", () => {
],
})
})
+
+ it("creates nested output directories for item names with path segments", async () => {
+ const cwd = await createFixture({
+ "registry.json": JSON.stringify({
+ name: "example",
+ homepage: "https://example.com",
+ items: [
+ {
+ name: "extension/foo",
+ type: "registry:item",
+ files: [
+ {
+ path: "registry/extensions/foo.tsx",
+ type: "registry:file",
+ target: "extensions/foo.tsx",
+ },
+ ],
+ },
+ ],
+ }),
+ "registry/extensions/foo.tsx": "export function Foo() {}",
+ })
+
+ await build.parseAsync(
+ ["node", "shadcn", "registry.json", "--cwd", cwd, "--output", "public/r"],
+ { from: "node" }
+ )
+
+ const item = JSON.parse(
+ await fs.readFile(path.join(cwd, "public/r/extension/foo.json"), "utf-8")
+ )
+
+ expect(item).toMatchObject({
+ name: "extension/foo",
+ files: [
+ {
+ path: "registry/extensions/foo.tsx",
+ content: "export function Foo() {}",
+ },
+ ],
+ })
+ })
})
async function createFixture(files: Record) {
diff --git a/packages/shadcn/src/commands/build.ts b/packages/shadcn/src/commands/build.ts
index f6426c92693..5b9ba00cf33 100644
--- a/packages/shadcn/src/commands/build.ts
+++ b/packages/shadcn/src/commands/build.ts
@@ -69,11 +69,15 @@ export const build = new Command()
)
// Write the registry item to the output directory.
+ // Item names can contain path segments (e.g. "extension/foo"), so
+ // ensure the nested output directory exists before writing.
+ const outputPath = path.resolve(
+ resolvePaths.outputDir,
+ `${registryItemForBuild.name}.json`
+ )
+ await fs.mkdir(path.dirname(outputPath), { recursive: true })
await fs.writeFile(
- path.resolve(
- resolvePaths.outputDir,
- `${registryItemForBuild.name}.json`
- ),
+ outputPath,
JSON.stringify(registryItemForBuild, null, 2)
)
}
diff --git a/packages/shadcn/src/commands/registry/build.ts b/packages/shadcn/src/commands/registry/build.ts
index 942640684d8..aa0887cdb56 100644
--- a/packages/shadcn/src/commands/registry/build.ts
+++ b/packages/shadcn/src/commands/registry/build.ts
@@ -149,10 +149,14 @@ async function buildRegistry(opts: z.infer) {
}
// Write the registry item to the output directory.
- await fs.writeFile(
- path.resolve(resolvePaths.outputDir, `${result.data.name}.json`),
- JSON.stringify(result.data, null, 2)
+ // Item names can contain path segments (e.g. "extension/foo"), so
+ // ensure the nested output directory exists before writing.
+ const outputPath = path.resolve(
+ resolvePaths.outputDir,
+ `${result.data.name}.json`
)
+ await fs.mkdir(path.dirname(outputPath), { recursive: true })
+ await fs.writeFile(outputPath, JSON.stringify(result.data, null, 2))
}
// Copy registry.json to the output directory.
diff --git a/packages/shadcn/src/commands/search.ts b/packages/shadcn/src/commands/search.ts
index a9a5e6f58a6..3c540fdaeb9 100644
--- a/packages/shadcn/src/commands/search.ts
+++ b/packages/shadcn/src/commands/search.ts
@@ -1,5 +1,6 @@
import path from "path"
import { configWithDefaults } from "@/src/registry/config"
+import { BUILTIN_REGISTRIES } from "@/src/registry/constants"
import { clearRegistryContext } from "@/src/registry/context"
import {
findUnknownSearchTypes,
@@ -143,13 +144,20 @@ export const search = new Command()
process.exit(1)
}
- // Only namespace registries passed explicitly need to be discovered and
- // added to the config. Registries already configured in components.json
- // are resolved directly from the config below.
+ // Only namespace registries that are not already configured need to be
+ // discovered and added to the config. Registries in components.json (or
+ // builtins) are resolved directly from the config below. Skipping
+ // configured registries also avoids the discovery step downloading the
+ // full catalog before the search request.
const { config: updatedConfig, newRegistries } =
await ensureRegistriesInConfig(
registries
- .filter((registry) => registry.startsWith("@"))
+ .filter(
+ (registry) =>
+ registry.startsWith("@") &&
+ !config.registries?.[registry] &&
+ !(registry in BUILTIN_REGISTRIES)
+ )
.map((registry) => `${registry}/registry`),
config,
{
diff --git a/packages/shadcn/src/registry/api.test.ts b/packages/shadcn/src/registry/api.test.ts
index 6d40732525d..345d198182f 100644
--- a/packages/shadcn/src/registry/api.test.ts
+++ b/packages/shadcn/src/registry/api.test.ts
@@ -791,6 +791,185 @@ describe("getRegistry", () => {
expect(receivedHeaders.authorization).toBe("Bearer test-token")
})
+ it("should forward search params as query params", async () => {
+ let receivedUrl = ""
+ server.use(
+ http.get("https://acme.com/registry.json", ({ request }) => {
+ receivedUrl = request.url
+ return HttpResponse.json({
+ name: "@acme/registry",
+ homepage: "https://acme.com",
+ items: [],
+ })
+ })
+ )
+
+ const mockConfig = {
+ style: "new-york",
+ tailwind: { baseColor: "neutral", cssVariables: true },
+ registries: {
+ "@acme": {
+ url: "https://acme.com/{name}.json",
+ },
+ },
+ } as any
+
+ await getRegistry("@acme", {
+ config: mockConfig,
+ searchParams: {
+ query: "button",
+ types: ["registry:ui", "registry:block"],
+ limit: 50,
+ offset: 10,
+ },
+ })
+
+ const url = new URL(receivedUrl)
+ expect(url.searchParams.get("q")).toBe("button")
+ expect(url.searchParams.get("type")).toBe("registry:ui,registry:block")
+ expect(url.searchParams.get("limit")).toBe("50")
+ expect(url.searchParams.get("offset")).toBe("10")
+ })
+
+ it("should not append query params without search params", async () => {
+ let receivedUrl = ""
+ server.use(
+ http.get("https://acme.com/registry.json", ({ request }) => {
+ receivedUrl = request.url
+ return HttpResponse.json({
+ name: "@acme/registry",
+ homepage: "https://acme.com",
+ items: [],
+ })
+ })
+ )
+
+ const mockConfig = {
+ style: "new-york",
+ tailwind: { baseColor: "neutral", cssVariables: true },
+ registries: {
+ "@acme": {
+ url: "https://acme.com/{name}.json",
+ },
+ },
+ } as any
+
+ await getRegistry("@acme", { config: mockConfig })
+
+ expect(receivedUrl).toBe("https://acme.com/registry.json")
+ })
+
+ it("should forward search params on direct registry URLs", async () => {
+ let receivedUrl = ""
+ server.use(
+ http.get("https://acme.com/r/registry.json", ({ request }) => {
+ receivedUrl = request.url
+ return HttpResponse.json({
+ name: "@acme/registry",
+ homepage: "https://acme.com",
+ items: [],
+ })
+ })
+ )
+
+ await getRegistry("https://acme.com/r/registry.json", {
+ searchParams: {
+ query: "button",
+ limit: 10,
+ },
+ })
+
+ const url = new URL(receivedUrl)
+ expect(url.searchParams.get("q")).toBe("button")
+ expect(url.searchParams.get("limit")).toBe("10")
+ expect(url.searchParams.get("offset")).toBeNull()
+ expect(url.searchParams.get("type")).toBeNull()
+ })
+
+ it("should preserve configured auth params when appending search params", async () => {
+ let receivedUrl = ""
+ let receivedAuthHeaders: Record = {}
+ server.use(
+ http.get("https://private.com/registry.json", ({ request }) => {
+ receivedUrl = request.url
+ request.headers.forEach((value, key) => {
+ receivedAuthHeaders[key] = value
+ })
+ return HttpResponse.json({
+ name: "@private/registry",
+ homepage: "https://private.com",
+ items: [],
+ })
+ })
+ )
+
+ const mockConfig = {
+ style: "new-york",
+ tailwind: { baseColor: "neutral", cssVariables: true },
+ registries: {
+ "@private": {
+ url: "https://private.com/{name}.json",
+ params: {
+ token: "test-token",
+ },
+ headers: {
+ Authorization: "Bearer test-token",
+ },
+ },
+ },
+ } as any
+
+ await getRegistry("@private", {
+ config: mockConfig,
+ searchParams: { query: "button" },
+ })
+
+ const url = new URL(receivedUrl)
+ expect(url.searchParams.get("token")).toBe("test-token")
+ expect(url.searchParams.get("q")).toBe("button")
+ expect(receivedAuthHeaders.authorization).toBe("Bearer test-token")
+ })
+
+ it("should return pagination from dynamic registries", async () => {
+ server.use(
+ http.get("https://acme.com/registry.json", () => {
+ return HttpResponse.json({
+ name: "@acme/registry",
+ homepage: "https://acme.com",
+ items: [{ name: "button", type: "registry:ui" }],
+ pagination: {
+ total: 1000,
+ offset: 0,
+ limit: 1,
+ hasMore: true,
+ },
+ })
+ })
+ )
+
+ const mockConfig = {
+ style: "new-york",
+ tailwind: { baseColor: "neutral", cssVariables: true },
+ registries: {
+ "@acme": {
+ url: "https://acme.com/{name}.json",
+ },
+ },
+ } as any
+
+ const result = await getRegistry("@acme", {
+ config: mockConfig,
+ searchParams: { query: "button" },
+ })
+
+ expect(result.pagination).toEqual({
+ total: 1000,
+ offset: 0,
+ limit: 1,
+ hasMore: true,
+ })
+ })
+
it("should throw RegistryNotConfiguredError when registry is not configured", async () => {
const mockConfig = {
style: "new-york",
diff --git a/packages/shadcn/src/registry/api.ts b/packages/shadcn/src/registry/api.ts
index ce6b3874e66..dd9ecc869df 100644
--- a/packages/shadcn/src/registry/api.ts
+++ b/packages/shadcn/src/registry/api.ts
@@ -56,21 +56,67 @@ type RegistryApiOptions = {
useCache?: boolean
}
-export async function getRegistry(name: string, options?: RegistryApiOptions) {
+// Search parameters forwarded to the registry as query params so dynamic
+// registries can filter server-side. Static registries ignore them and return
+// the full catalog. See https://ui.shadcn.com/docs/registry/dynamic-search.
+type RegistrySearchParams = {
+ query?: string
+ types?: string[]
+ limit?: number
+ offset?: number
+}
+
+type GetRegistryOptions = RegistryApiOptions & {
+ searchParams?: RegistrySearchParams
+}
+
+function appendSearchParamsToUrl(
+ url: string,
+ searchParams?: RegistrySearchParams
+) {
+ if (!searchParams) {
+ return url
+ }
+
+ const parsedUrl = new URL(url)
+
+ if (searchParams.query) {
+ parsedUrl.searchParams.set("q", searchParams.query)
+ }
+
+ if (searchParams.types?.length) {
+ parsedUrl.searchParams.set("type", searchParams.types.join(","))
+ }
+
+ if (searchParams.limit !== undefined) {
+ parsedUrl.searchParams.set("limit", String(searchParams.limit))
+ }
+
+ if (searchParams.offset !== undefined) {
+ parsedUrl.searchParams.set("offset", String(searchParams.offset))
+ }
+
+ return parsedUrl.toString()
+}
+
+export async function getRegistry(name: string, options?: GetRegistryOptions) {
return withRegistryContext(() => getRegistryWithContext(name, options))
}
async function getRegistryWithContext(
name: string,
- options?: RegistryApiOptions
+ options?: GetRegistryOptions
) {
- const { config, useCache } = options || {}
+ const { config, useCache, searchParams } = options || {}
if (isUrl(name)) {
- const [result] = await fetchRegistry([name], { useCache })
+ const url = appendSearchParamsToUrl(name, searchParams)
+ const [result] = await fetchRegistry([url], { useCache })
return parseRegistryCatalog(name, result)
}
+ // GitHub registries are raw files. There is no server to run a search, so
+ // search params are not forwarded and filtering happens locally.
const githubSource = resolveGitHubRegistrySource(name)
if (githubSource) {
return fetchGitHubRegistryCatalog(githubSource, { useCache })
@@ -94,13 +140,17 @@ async function getRegistryWithContext(
throw new RegistryNotFoundError(registryName)
}
+ // Append search params before registering headers so the header lookup key
+ // matches the URL we actually fetch.
+ const url = appendSearchParamsToUrl(urlAndHeaders.url, searchParams)
+
if (urlAndHeaders.headers && Object.keys(urlAndHeaders.headers).length > 0) {
setRegistryHeaders({
- [urlAndHeaders.url]: urlAndHeaders.headers,
+ [url]: urlAndHeaders.headers,
})
}
- const [result] = await fetchRegistry([urlAndHeaders.url], { useCache })
+ const [result] = await fetchRegistry([url], { useCache })
return parseRegistryCatalog(registryName, result)
}
diff --git a/packages/shadcn/src/registry/schema.test.ts b/packages/shadcn/src/registry/schema.test.ts
index 808b5d8ae9f..8115dc9676c 100644
--- a/packages/shadcn/src/registry/schema.test.ts
+++ b/packages/shadcn/src/registry/schema.test.ts
@@ -80,4 +80,54 @@ describe("registrySchema", () => {
expect(result.success).toBe(false)
})
+
+ it("should accept registries with pagination", () => {
+ const result = registrySchema.safeParse({
+ name: "example",
+ homepage: "https://example.com",
+ items: [{ name: "button", type: "registry:ui" }],
+ pagination: {
+ total: 100,
+ offset: 0,
+ limit: 1,
+ hasMore: true,
+ },
+ })
+
+ expect(result.success).toBe(true)
+ if (result.success) {
+ expect(result.data.pagination).toEqual({
+ total: 100,
+ offset: 0,
+ limit: 1,
+ hasMore: true,
+ })
+ }
+ })
+
+ it("should accept registries without pagination", () => {
+ const result = registrySchema.safeParse({
+ name: "example",
+ homepage: "https://example.com",
+ items: [{ name: "button", type: "registry:ui" }],
+ })
+
+ expect(result.success).toBe(true)
+ if (result.success) {
+ expect(result.data.pagination).toBeUndefined()
+ }
+ })
+
+ it("should reject registries with invalid pagination", () => {
+ const result = registrySchema.safeParse({
+ name: "example",
+ homepage: "https://example.com",
+ items: [{ name: "button", type: "registry:ui" }],
+ pagination: {
+ total: "100",
+ },
+ })
+
+ expect(result.success).toBe(false)
+ })
})
diff --git a/packages/shadcn/src/registry/schema.ts b/packages/shadcn/src/registry/schema.ts
index 7948ee35be7..c9b144e277b 100644
--- a/packages/shadcn/src/registry/schema.ts
+++ b/packages/shadcn/src/registry/schema.ts
@@ -198,6 +198,16 @@ export type RegistryBaseItem = Extract
// Helper type for registry:font items specifically.
export type RegistryFontItem = Extract
+// Pagination metadata returned by registries that implement dynamic search.
+// Its presence on a catalog response signals that the items are already
+// filtered and paginated server-side.
+export const registryPaginationSchema = z.object({
+ total: z.number(),
+ offset: z.number(),
+ limit: z.number(),
+ hasMore: z.boolean(),
+})
+
const registryBaseSchema = z
.object({
$schema: z.string().optional(),
@@ -205,6 +215,7 @@ const registryBaseSchema = z
homepage: z.string().optional(),
include: z.array(z.string()).optional(),
items: z.array(registryItemSchema).optional(),
+ pagination: registryPaginationSchema.optional(),
})
.refine(
(registry) =>
@@ -227,6 +238,7 @@ export const registrySchema = registryChunkSchema.pipe(
homepage: z.string(),
include: z.array(z.string()).optional(),
items: z.array(registryItemSchema),
+ pagination: registryPaginationSchema.optional(),
})
)
@@ -293,12 +305,7 @@ export const searchResultErrorSchema = z.object({
})
export const searchResultsSchema = z.object({
- pagination: z.object({
- total: z.number(),
- offset: z.number(),
- limit: z.number(),
- hasMore: z.boolean(),
- }),
+ pagination: registryPaginationSchema,
items: z.array(searchResultItemSchema),
// Registries that failed to load during the search. Only present when a
// search tolerates per-registry failures (see searchRegistries'
diff --git a/packages/shadcn/src/registry/search.test.ts b/packages/shadcn/src/registry/search.test.ts
index ff11f1545dd..b4a0aff7d57 100644
--- a/packages/shadcn/src/registry/search.test.ts
+++ b/packages/shadcn/src/registry/search.test.ts
@@ -1087,3 +1087,296 @@ describe("findUnknownSearchTypes", () => {
expect(findUnknownSearchTypes(["internal"])).toEqual(["internal"])
})
})
+
+describe("searchRegistries with dynamic registries", () => {
+ it("forwards search params to the registry", async () => {
+ const mockGetRegistry = vi.mocked(getRegistry)
+
+ mockGetRegistry.mockResolvedValue({
+ name: "acme",
+ homepage: "https://acme.com",
+ items: [],
+ })
+
+ await searchRegistries(["@acme"], {
+ query: "button",
+ types: ["ui", "registry:block"],
+ limit: 20,
+ offset: 40,
+ })
+
+ expect(mockGetRegistry).toHaveBeenCalledWith(
+ "@acme",
+ expect.objectContaining({
+ searchParams: {
+ query: "button",
+ types: ["registry:ui", "registry:block"],
+ limit: 20,
+ offset: 40,
+ },
+ })
+ )
+
+ mockGetRegistry.mockRestore()
+ })
+
+ it("pushes down filters but not offset when searching multiple registries", async () => {
+ const mockGetRegistry = vi.mocked(getRegistry)
+
+ mockGetRegistry.mockResolvedValue({
+ name: "acme",
+ homepage: "https://acme.com",
+ items: [],
+ })
+
+ await searchRegistries(["@one", "@two"], {
+ query: "button",
+ limit: 10,
+ offset: 20,
+ })
+
+ // Each registry is over-fetched (offset + limit) so the requested page
+ // can be filled after the merge.
+ for (const registry of ["@one", "@two"]) {
+ expect(mockGetRegistry).toHaveBeenCalledWith(
+ registry,
+ expect.objectContaining({
+ searchParams: {
+ query: "button",
+ types: undefined,
+ limit: 30,
+ offset: undefined,
+ },
+ })
+ )
+ }
+
+ mockGetRegistry.mockRestore()
+ })
+
+ it("trusts server results when a single dynamic registry is searched", async () => {
+ const mockGetRegistry = vi.mocked(getRegistry)
+
+ // The server returns items that would not match a local fuzzy search to
+ // prove no local filtering is applied on top.
+ mockGetRegistry.mockResolvedValue({
+ name: "acme",
+ homepage: "https://acme.com",
+ items: [
+ {
+ name: "unrelated-item",
+ type: "registry:ui",
+ description: "Does not mention the query.",
+ },
+ ],
+ pagination: {
+ total: 500,
+ offset: 10,
+ limit: 1,
+ hasMore: true,
+ },
+ })
+
+ const results = await searchRegistries(["@acme"], {
+ query: "button",
+ limit: 1,
+ offset: 10,
+ })
+
+ expect(results).toEqual({
+ items: [
+ {
+ name: "unrelated-item",
+ type: "registry:ui",
+ description: "Does not mention the query.",
+ registry: "@acme",
+ addCommandArgument: "@acme/unrelated-item",
+ },
+ ],
+ pagination: {
+ total: 500,
+ offset: 10,
+ limit: 1,
+ hasMore: true,
+ },
+ })
+
+ mockGetRegistry.mockRestore()
+ })
+
+ it("merges dynamic and static registries", async () => {
+ const mockGetRegistry = vi.mocked(getRegistry)
+
+ mockGetRegistry.mockImplementation(async (name: string) => {
+ if (name === "@dynamic") {
+ return {
+ name: "dynamic",
+ homepage: "https://dynamic.com",
+ items: [
+ {
+ name: "button",
+ type: "registry:ui",
+ description: "A server-filtered button.",
+ },
+ ],
+ pagination: {
+ total: 42,
+ offset: 0,
+ limit: 1,
+ hasMore: true,
+ },
+ }
+ }
+ if (name === "@static") {
+ return {
+ name: "static",
+ homepage: "https://static.com",
+ items: [
+ {
+ name: "button-group",
+ type: "registry:ui",
+ description: "A button group.",
+ },
+ {
+ name: "card",
+ type: "registry:ui",
+ description: "A card component.",
+ },
+ ],
+ }
+ }
+ throw new Error(`Unknown registry: ${name}`)
+ })
+
+ const results = await searchRegistries(["@dynamic", "@static"], {
+ query: "button",
+ })
+
+ // Server-filtered items are kept as-is. Static items still go through
+ // the local fuzzy filter, which drops "card".
+ expect(results.items).toEqual([
+ {
+ name: "button",
+ type: "registry:ui",
+ description: "A server-filtered button.",
+ registry: "@dynamic",
+ addCommandArgument: "@dynamic/button",
+ },
+ {
+ name: "button-group",
+ type: "registry:ui",
+ description: "A button group.",
+ registry: "@static",
+ addCommandArgument: "@static/button-group",
+ },
+ ])
+
+ // The dynamic registry's total includes matches beyond the returned
+ // items. No limit was requested, so the identical request would be sent
+ // again for a deeper page — the tail is unreachable and hasMore is false.
+ expect(results.pagination.total).toBe(43)
+ expect(results.pagination.hasMore).toBe(false)
+
+ mockGetRegistry.mockRestore()
+ })
+
+ it("reports more pages when a dynamic registry fills the requested limit", async () => {
+ const mockGetRegistry = vi.mocked(getRegistry)
+
+ mockGetRegistry.mockImplementation(async (name: string) => {
+ if (name === "@dynamic") {
+ return {
+ name: "dynamic",
+ homepage: "https://dynamic.com",
+ items: [
+ { name: "button", type: "registry:ui" },
+ { name: "button-group", type: "registry:ui" },
+ ],
+ pagination: {
+ total: 42,
+ offset: 0,
+ limit: 2,
+ hasMore: true,
+ },
+ }
+ }
+ return {
+ name: "static",
+ homepage: "https://static.com",
+ items: [],
+ }
+ })
+
+ const results = await searchRegistries(["@dynamic", "@static"], {
+ query: "button",
+ limit: 2,
+ })
+
+ // The registry filled the requested limit, so deeper pages re-request it
+ // with a larger limit and can surface the remaining matches.
+ expect(results.items).toHaveLength(2)
+ expect(results.pagination.total).toBe(42)
+ expect(results.pagination.hasMore).toBe(true)
+
+ mockGetRegistry.mockRestore()
+ })
+
+ it("does not report more pages when a dynamic registry caps its response", async () => {
+ const mockGetRegistry = vi.mocked(getRegistry)
+
+ // The registry claims 500 matches but caps every response at one item,
+ // regardless of the requested limit.
+ mockGetRegistry.mockResolvedValue({
+ name: "capped",
+ homepage: "https://capped.com",
+ items: [{ name: "item-0", type: "registry:ui" }],
+ pagination: {
+ total: 500,
+ offset: 0,
+ limit: 1,
+ hasMore: true,
+ },
+ })
+
+ const results = await searchRegistries(["@capped", "@static"], {
+ limit: 5,
+ offset: 10,
+ })
+
+ // The page beyond the cap is empty. hasMore must be false so paging
+ // stops instead of looping through empty pages toward total.
+ expect(results.items).toEqual([])
+ expect(results.pagination.hasMore).toBe(false)
+
+ mockGetRegistry.mockRestore()
+ })
+
+ it("keeps local pagination when no registry returns pagination", async () => {
+ const mockGetRegistry = vi.mocked(getRegistry)
+
+ mockGetRegistry.mockResolvedValue({
+ name: "static",
+ homepage: "https://static.com",
+ items: [
+ { name: "button", type: "registry:ui" },
+ { name: "card", type: "registry:ui" },
+ { name: "input", type: "registry:ui" },
+ ],
+ })
+
+ const results = await searchRegistries(["@static"], {
+ limit: 2,
+ offset: 1,
+ })
+
+ expect(results.items.map((item) => item.name)).toEqual(["card", "input"])
+ expect(results.pagination).toEqual({
+ total: 3,
+ offset: 1,
+ limit: 2,
+ hasMore: false,
+ })
+
+ mockGetRegistry.mockRestore()
+ })
+})
diff --git a/packages/shadcn/src/registry/search.ts b/packages/shadcn/src/registry/search.ts
index 2f0665ecb7a..11f8dc07352 100644
--- a/packages/shadcn/src/registry/search.ts
+++ b/packages/shadcn/src/registry/search.ts
@@ -1,5 +1,6 @@
import {
registryItemTypeSchema,
+ registryPaginationSchema,
searchResultErrorSchema,
searchResultItemSchema,
searchResultsSchema,
@@ -103,18 +104,42 @@ async function searchRegistriesWithContext(
continueOnError,
} = options || {}
- let allItems: z.infer[] = []
const errors: z.infer[] = []
+ // Search params are forwarded to every registry so dynamic registries can
+ // filter server-side. Pagination only composes when a single registry is
+ // searched. Across multiple registries a global offset cannot be
+ // distributed, so we push down filters only and over-fetch enough items
+ // (offset + limit) from each registry to fill the requested page locally.
+ const isSingleRegistry = registries.length === 1
+ const wireTypes = types?.map((type) => toRegistryItemType(type))
+ const searchParams = isSingleRegistry
+ ? { query, types: wireTypes, limit, offset }
+ : {
+ query,
+ types: wireTypes,
+ limit: limit !== undefined ? (offset || 0) + limit : undefined,
+ offset: undefined,
+ }
+
// Fetch registries concurrently (capped), then process the results in the
// original order so the output is deterministic regardless of which
// responses land first. This matters most when searching many registries.
const outcomes = await mapSettledWithConcurrency(
registries,
SEARCH_CONCURRENCY,
- (registry) => getRegistry(registry, { config, useCache })
+ (registry) => getRegistry(registry, { config, useCache, searchParams })
)
+ // A registry that returns `pagination` has already filtered and paginated
+ // its items server-side (see the dynamic search docs). Its items are kept
+ // as-is while items from static registries go through the local pipeline.
+ let localItems: z.infer[] = []
+ const serverResults: {
+ items: z.infer[]
+ pagination: z.infer
+ }[] = []
+
for (let index = 0; index < registries.length; index++) {
const registry = registries[index]
const outcome = outcomes[index]
@@ -144,7 +169,25 @@ async function searchRegistriesWithContext(
),
}))
- allItems = allItems.concat(itemsWithRegistry)
+ if (outcome.value.pagination) {
+ serverResults.push({
+ items: itemsWithRegistry,
+ pagination: outcome.value.pagination,
+ })
+ continue
+ }
+
+ localItems = localItems.concat(itemsWithRegistry)
+ }
+
+ // Single dynamic registry: the server did all the work. Return its items
+ // and pagination verbatim so ranking and totals are the server's.
+ if (isSingleRegistry && serverResults.length === 1) {
+ const [serverResult] = serverResults
+ return searchResultsSchema.parse({
+ pagination: serverResult.pagination,
+ items: serverResult.items,
+ })
}
// Filter by type before the fuzzy query. Accepts both shorthand ("ui") and
@@ -153,7 +196,7 @@ async function searchRegistriesWithContext(
const wantedTypes = new Set(
types.map((type) => formatSearchResultType(type).toLowerCase())
)
- allItems = allItems.filter(
+ localItems = localItems.filter(
(item) =>
item.type &&
wantedTypes.has(formatSearchResultType(item.type).toLowerCase())
@@ -161,23 +204,48 @@ async function searchRegistriesWithContext(
}
if (query) {
- allItems = searchItems(allItems, {
+ localItems = searchItems(localItems, {
query,
- limit: allItems.length,
+ limit: localItems.length,
keys: ["name", "description"],
}) as z.infer[]
}
+ // Merge pre-filtered items (in registry order) with locally filtered items,
+ // then paginate the combined list. `total` includes matches a dynamic
+ // registry counted but did not return, so the match count stays accurate
+ // even when a registry truncated its response.
+ const allItems = serverResults
+ .flatMap((serverResult) => serverResult.items)
+ .concat(localItems)
+ const serverTotal = serverResults.reduce(
+ (sum, serverResult) => sum + serverResult.pagination.total,
+ 0
+ )
+
const paginationOffset = offset || 0
const paginationLimit = limit || allItems.length
- const totalItems = allItems.length
+ const totalItems = serverTotal + localItems.length
+
+ // Deeper pages re-request every registry with a larger over-fetch limit, so
+ // a dynamic registry can serve them only if it filled the current request.
+ // A registry that returned fewer items than requested is capped and its
+ // remaining matches are unreachable through paging — it must not drive
+ // `hasMore`, or paging would loop through empty pages forever.
+ const serverHasMore = serverResults.some(
+ (serverResult) =>
+ serverResult.pagination.hasMore &&
+ searchParams.limit !== undefined &&
+ serverResult.items.length >= searchParams.limit
+ )
const result: z.infer = {
pagination: {
total: totalItems,
offset: paginationOffset,
limit: paginationLimit,
- hasMore: paginationOffset + paginationLimit < totalItems,
+ hasMore:
+ paginationOffset + paginationLimit < allItems.length || serverHasMore,
},
items: allItems.slice(paginationOffset, paginationOffset + paginationLimit),
// Only surface errors when present so consumers parsing successful
@@ -316,6 +384,12 @@ export function formatSearchResultType(type?: string) {
return type.startsWith("registry:") ? type.slice("registry:".length) : type
}
+// Inverse of formatSearchResultType. Normalizes a type filter to the full
+// namespaced form for the wire, e.g. "ui" -> "registry:ui".
+export function toRegistryItemType(type: string) {
+ return type.startsWith("registry:") ? type : `registry:${type}`
+}
+
// Internal-only types that should not be offered as a --type filter.
const INTERNAL_TYPES = ["registry:example", "registry:internal"]
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a2fefa5b34a..db0bb755de5 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -328,7 +328,7 @@ importers:
specifier: ^0.0.1
version: 0.0.1
shadcn:
- specifier: 4.16.0
+ specifier: 4.16.1
version: link:../../packages/shadcn
shiki:
specifier: ^3.23.0