From 652522703637e7abb2be482ea4d6d7f0ebc8046a Mon Sep 17 00:00:00 2001
From: Ethan Davidson <31261035+EthanThatOneKid@users.noreply.github.com>
Date: Thu, 26 Mar 2026 00:24:48 -0700
Subject: [PATCH 1/5] fix(cli): add Accept and User-Agent headers to support
content negotiation (fixes #10164)
---
.../content/docs/registry/getting-started.mdx | 41 ++++++++++++
packages/shadcn/src/registry/fetcher.test.ts | 67 +++++++++++++++++++
packages/shadcn/src/registry/fetcher.ts | 2 +
3 files changed, 110 insertions(+)
diff --git a/apps/v4/content/docs/registry/getting-started.mdx b/apps/v4/content/docs/registry/getting-started.mdx
index 1616b69980..675570d7de 100644
--- a/apps/v4/content/docs/registry/getting-started.mdx
+++ b/apps/v4/content/docs/registry/getting-started.mdx
@@ -144,6 +144,47 @@ npm run dev
Your files will now be served at `http://localhost:3000/r/[NAME].json` eg. `http://localhost:3000/r/hello-world.json`.
+## Content Negotiation
+
+The `shadcn` CLI supports **HTTP Content Negotiation**. This allows you to host your registry at any endpoint (including the root of your domain) and serve different content based on the client.
+
+### Identity Headers
+
+When the CLI makes a request to a registry, it sends the following headers:
+
+- **User-Agent**: `shadcn-ui`
+- **Accept**: `application/vnd.shadcn.v1+json, application/json;q=0.9`
+
+### Root Hosting
+
+By checking these headers on your server, you can serve human-readable documentation (HTML) to browser users and the registry index (JSON) to the CLI—all from the exact same URL.
+
+Here's an example of how to implement this in an Express.js server:
+
+```javascript title="server.js" showLineNumbers
+app.get("/", (req, res) => {
+ const accept = req.get("Accept")
+ const userAgent = req.get("User-Agent")
+
+ // 1. Check if the request is from the shadcn CLI
+ if (
+ accept?.includes("application/vnd.shadcn.v1+json") ||
+ userAgent === "shadcn-ui"
+ ) {
+ // Return the registry JSON for the CLI
+ return res.json(registryData)
+ }
+
+ // 2. Otherwise, serve your documentation or homepage
+ res.send(htmlContent)
+})
+```
+
+This enables:
+- **Branded Registry URLs**: `shadcn add https://ui.example.com`
+- **Shorter URLs**: No need for dedicated `/r/` or `/registry/` sub-paths.
+- **Easy Mnemonics**: Easier for users to remember and share your registry.
+
## Publish your registry
To make your registry available to other developers, you can publish it by deploying your project to a public URL.
diff --git a/packages/shadcn/src/registry/fetcher.test.ts b/packages/shadcn/src/registry/fetcher.test.ts
index 549addb04e..608672ed97 100644
--- a/packages/shadcn/src/registry/fetcher.test.ts
+++ b/packages/shadcn/src/registry/fetcher.test.ts
@@ -204,6 +204,73 @@ describe("fetchRegistry", () => {
expect(result[0]).toMatchObject({ name: "button" })
expect(result[1]).toMatchObject({ name: "card" })
})
+
+ it("should send specific Accept and User-Agent headers", async () => {
+ let acceptHeader: string | null = null
+ let userAgentHeader: string | null = null
+ server.use(
+ http.get(`${REGISTRY_URL}/header-test.json`, ({ request }) => {
+ acceptHeader = request.headers.get("accept")
+ userAgentHeader = request.headers.get("user-agent")
+ return HttpResponse.json({
+ name: "header-test",
+ type: "registry:ui",
+ })
+ })
+ )
+
+ await fetchRegistry(["header-test.json"], { useCache: false })
+ expect(acceptHeader).toBe(
+ "application/vnd.shadcn.v1+json, application/json;q=0.9"
+ )
+ expect(userAgentHeader).toBe("shadcn-ui")
+ })
+
+ it("should send specific Accept header for direct external URLs", async () => {
+ let acceptHeader: string | null = null
+ server.use(
+ http.get("https://external.com/registry/item.json", ({ request }) => {
+ acceptHeader = request.headers.get("accept")
+ return HttpResponse.json({
+ name: "item",
+ type: "registry:ui",
+ })
+ })
+ )
+
+ await fetchRegistry(["https://external.com/registry/item.json"], {
+ useCache: false,
+ })
+ expect(acceptHeader).toBe(
+ "application/vnd.shadcn.v1+json, application/json;q=0.9"
+ )
+ })
+
+ it("should successfully fetch when the server requires the specific Shadcn Accept header (Content Negotiation)", async () => {
+ server.use(
+ http.get(`${REGISTRY_URL}/content-negotiation.json`, ({ request }) => {
+ const accept = request.headers.get("accept")
+ if (!accept?.includes("application/vnd.shadcn.v1+json")) {
+ return new HttpResponse(
+ "
Error: Specific header missing",
+ {
+ status: 200,
+ headers: { "Content-Type": "text/html" },
+ }
+ )
+ }
+ return HttpResponse.json({
+ name: "content-negotiation",
+ type: "registry:ui",
+ })
+ })
+ )
+
+ const [result] = await fetchRegistry(["content-negotiation.json"], {
+ useCache: false,
+ })
+ expect(result).toMatchObject({ name: "content-negotiation" })
+ })
})
describe("clearRegistryCache", () => {
diff --git a/packages/shadcn/src/registry/fetcher.ts b/packages/shadcn/src/registry/fetcher.ts
index e5df988310..33d34e3ac9 100644
--- a/packages/shadcn/src/registry/fetcher.ts
+++ b/packages/shadcn/src/registry/fetcher.ts
@@ -54,6 +54,8 @@ export async function fetchRegistry(
const response = await fetch(url, {
agent,
headers: {
+ Accept: "application/vnd.shadcn.v1+json, application/json;q=0.9",
+ "User-Agent": "shadcn-ui",
...headers,
},
})
From f9b216af77f6e3b0eec57489c1a472a356e3befb Mon Sep 17 00:00:00 2001
From: Ethan Davidson <31261035+EthanThatOneKid@users.noreply.github.com>
Date: Thu, 26 Mar 2026 00:24:48 -0700
Subject: [PATCH 2/5] docs(registry): document content negotiation with Express
example
---
.../content/docs/registry/getting-started.mdx | 25 +++++++++----------
1 file changed, 12 insertions(+), 13 deletions(-)
diff --git a/apps/v4/content/docs/registry/getting-started.mdx b/apps/v4/content/docs/registry/getting-started.mdx
index 675570d7de..b418c4f6be 100644
--- a/apps/v4/content/docs/registry/getting-started.mdx
+++ b/apps/v4/content/docs/registry/getting-started.mdx
@@ -144,18 +144,18 @@ npm run dev
Your files will now be served at `http://localhost:3000/r/[NAME].json` eg. `http://localhost:3000/r/hello-world.json`.
-## Content Negotiation
+## Content negotiation
The `shadcn` CLI supports **HTTP Content Negotiation**. This allows you to host your registry at any endpoint (including the root of your domain) and serve different content based on the client.
-### Identity Headers
+### Identity headers
When the CLI makes a request to a registry, it sends the following headers:
- **User-Agent**: `shadcn-ui`
- **Accept**: `application/vnd.shadcn.v1+json, application/json;q=0.9`
-### Root Hosting
+### Root hosting
By checking these headers on your server, you can serve human-readable documentation (HTML) to browser users and the registry index (JSON) to the CLI—all from the exact same URL.
@@ -163,24 +163,23 @@ Here's an example of how to implement this in an Express.js server:
```javascript title="server.js" showLineNumbers
app.get("/", (req, res) => {
- const accept = req.get("Accept")
- const userAgent = req.get("User-Agent")
-
- // 1. Check if the request is from the shadcn CLI
- if (
- accept?.includes("application/vnd.shadcn.v1+json") ||
- userAgent === "shadcn-ui"
- ) {
- // Return the registry JSON for the CLI
+ // Check if the client prefers the Shadcn vendor type
+ if (req.accepts("application/vnd.shadcn.v1+json")) {
return res.json(registryData)
}
- // 2. Otherwise, serve your documentation or homepage
+ // Optional: Secondary check for the User-Agent
+ if (req.get("User-Agent") === "shadcn-ui") {
+ return res.json(registryData)
+ }
+
+ // Otherwise, serve your documentation or homepage
res.send(htmlContent)
})
```
This enables:
+
- **Branded Registry URLs**: `shadcn add https://ui.example.com`
- **Shorter URLs**: No need for dedicated `/r/` or `/registry/` sub-paths.
- **Easy Mnemonics**: Easier for users to remember and share your registry.
From f632f5d7982bd08b4a47d50426732bc93d28033f Mon Sep 17 00:00:00 2001
From: shadcn
Date: Mon, 20 Apr 2026 11:55:06 +0400
Subject: [PATCH 3/5] feat: rename header
---
packages/shadcn/src/registry/fetcher.test.ts | 64 +++++++++++++++++++-
packages/shadcn/src/registry/fetcher.ts | 16 +++--
2 files changed, 73 insertions(+), 7 deletions(-)
diff --git a/packages/shadcn/src/registry/fetcher.test.ts b/packages/shadcn/src/registry/fetcher.test.ts
index 608672ed97..7d30448d3e 100644
--- a/packages/shadcn/src/registry/fetcher.test.ts
+++ b/packages/shadcn/src/registry/fetcher.test.ts
@@ -10,6 +10,7 @@ import { http, HttpResponse } from "msw"
import { setupServer } from "msw/node"
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"
+import { clearRegistryContext, setRegistryHeaders } from "./context"
import { clearRegistryCache, fetchRegistry } from "./fetcher"
const server = setupServer(
@@ -223,7 +224,68 @@ describe("fetchRegistry", () => {
expect(acceptHeader).toBe(
"application/vnd.shadcn.v1+json, application/json;q=0.9"
)
- expect(userAgentHeader).toBe("shadcn-ui")
+ expect(userAgentHeader).toBe("shadcn")
+ })
+
+ it("should allow per-registry headers to override the default Accept and User-Agent", async () => {
+ let acceptHeader: string | null = null
+ let userAgentHeader: string | null = null
+ server.use(
+ http.get(`${REGISTRY_URL}/override-test.json`, ({ request }) => {
+ acceptHeader = request.headers.get("accept")
+ userAgentHeader = request.headers.get("user-agent")
+ return HttpResponse.json({
+ name: "override-test",
+ type: "registry:ui",
+ })
+ })
+ )
+
+ setRegistryHeaders({
+ [`${REGISTRY_URL}/override-test.json`]: {
+ Accept: "application/custom+json",
+ "User-Agent": "custom-client/1.0",
+ },
+ })
+
+ await fetchRegistry(["override-test.json"], { useCache: false })
+
+ expect(acceptHeader).toBe("application/custom+json")
+ expect(userAgentHeader).toBe("custom-client/1.0")
+
+ clearRegistryContext()
+ })
+
+ it("should allow lowercase per-registry headers to override the default Accept and User-Agent", async () => {
+ let acceptHeader: string | null = null
+ let userAgentHeader: string | null = null
+ server.use(
+ http.get(
+ `${REGISTRY_URL}/lowercase-override-test.json`,
+ ({ request }) => {
+ acceptHeader = request.headers.get("accept")
+ userAgentHeader = request.headers.get("user-agent")
+ return HttpResponse.json({
+ name: "lowercase-override-test",
+ type: "registry:ui",
+ })
+ }
+ )
+ )
+
+ setRegistryHeaders({
+ [`${REGISTRY_URL}/lowercase-override-test.json`]: {
+ accept: "application/custom+json",
+ "user-agent": "custom-client/1.0",
+ },
+ })
+
+ await fetchRegistry(["lowercase-override-test.json"], { useCache: false })
+
+ expect(acceptHeader).toBe("application/custom+json")
+ expect(userAgentHeader).toBe("custom-client/1.0")
+
+ clearRegistryContext()
})
it("should send specific Accept header for direct external URLs", async () => {
diff --git a/packages/shadcn/src/registry/fetcher.ts b/packages/shadcn/src/registry/fetcher.ts
index 33d34e3ac9..8bc76265df 100644
--- a/packages/shadcn/src/registry/fetcher.ts
+++ b/packages/shadcn/src/registry/fetcher.ts
@@ -14,7 +14,7 @@ import {
} from "@/src/registry/errors"
import { registryItemSchema } from "@/src/schema"
import { HttpsProxyAgent } from "https-proxy-agent"
-import fetch from "node-fetch"
+import fetch, { Headers } from "node-fetch"
import { z } from "zod"
const agent = process.env.https_proxy
@@ -50,14 +50,18 @@ export async function fetchRegistry(
const fetchPromise = (async () => {
// Get headers from context for this URL.
const headers = getRegistryHeadersFromContext(url)
+ const requestHeaders = new Headers({
+ Accept: "application/vnd.shadcn.v1+json, application/json;q=0.9",
+ "User-Agent": "shadcn",
+ })
+
+ for (const [key, value] of Object.entries(headers)) {
+ requestHeaders.set(key, value)
+ }
const response = await fetch(url, {
agent,
- headers: {
- Accept: "application/vnd.shadcn.v1+json, application/json;q=0.9",
- "User-Agent": "shadcn-ui",
- ...headers,
- },
+ headers: requestHeaders,
})
if (!response.ok) {
From 4bdeea4c63e49313a5bb4110f6c035e6e93821a0 Mon Sep 17 00:00:00 2001
From: shadcn
Date: Mon, 20 Apr 2026 11:55:13 +0400
Subject: [PATCH 4/5] docs: update docs
---
.../content/docs/registry/getting-started.mdx | 80 ++++++++++++++++---
1 file changed, 70 insertions(+), 10 deletions(-)
diff --git a/apps/v4/content/docs/registry/getting-started.mdx b/apps/v4/content/docs/registry/getting-started.mdx
index b418c4f6be..2b96384a81 100644
--- a/apps/v4/content/docs/registry/getting-started.mdx
+++ b/apps/v4/content/docs/registry/getting-started.mdx
@@ -146,34 +146,94 @@ Your files will now be served at `http://localhost:3000/r/[NAME].json` eg. `http
## Content negotiation
-The `shadcn` CLI supports **HTTP Content Negotiation**. This allows you to host your registry at any endpoint (including the root of your domain) and serve different content based on the client.
+The `shadcn` CLI supports **HTTP Content Negotiation**. This allows you to host your registry at any endpoint — including the root of your domain — and serve different content depending on who is asking.
-### Identity headers
+From a single URL, you can serve:
+
+- **HTML** to browsers — a landing page, documentation, or marketing site.
+- **JSON** to the `shadcn` CLI — an installable registry item.
+- **Markdown** to AI agents and LLMs — a machine-readable version of your content.
+
+The client signals its preference using the `Accept` request header, and your server decides what to return.
+
+### Request headers
When the CLI makes a request to a registry, it sends the following headers:
-- **User-Agent**: `shadcn-ui`
+- **User-Agent**: `shadcn`
- **Accept**: `application/vnd.shadcn.v1+json, application/json;q=0.9`
### Root hosting
-By checking these headers on your server, you can serve human-readable documentation (HTML) to browser users and the registry index (JSON) to the CLI—all from the exact same URL.
+By checking these headers on your server, you can route CLI traffic to an installable registry item while keeping browser traffic flowing to your documentation or homepage.
-Here's an example of how to implement this in an Express.js server:
+The examples below assume your built registry item is served at `/r/index.json`. Adjust the path to match your output.
+
+In Next.js, express this as a rewrite in `next.config.ts`. This keeps the negotiation in the routing layer and avoids a Proxy function for this static rewrite:
+
+```typescript title="next.config.ts" showLineNumbers
+import type { NextConfig } from "next"
+
+const nextConfig: NextConfig = {
+ async rewrites() {
+ return {
+ beforeFiles: [
+ {
+ source: "/",
+ has: [
+ {
+ type: "header",
+ key: "accept",
+ value: "(.*)application/vnd\\.shadcn\\.v1\\+json(.*)",
+ },
+ ],
+ destination: "/r/index.json",
+ },
+ {
+ source: "/",
+ has: [
+ {
+ type: "header",
+ key: "user-agent",
+ value: "shadcn",
+ },
+ ],
+ destination: "/r/index.json",
+ },
+ ],
+ }
+ },
+ async headers() {
+ return [
+ {
+ source: "/",
+ headers: [{ key: "Vary", value: "Accept, User-Agent" }],
+ },
+ ]
+ },
+}
+
+export default nextConfig
+```
+
+Or, in an Express.js server:
```javascript title="server.js" showLineNumbers
app.get("/", (req, res) => {
- // Check if the client prefers the Shadcn vendor type
+ res.vary("Accept")
+ res.vary("User-Agent")
+
+ // Check if the client prefers the shadcn vendor type.
if (req.accepts("application/vnd.shadcn.v1+json")) {
return res.json(registryData)
}
- // Optional: Secondary check for the User-Agent
- if (req.get("User-Agent") === "shadcn-ui") {
+ // Optional: Secondary check for the User-Agent.
+ if (req.get("User-Agent") === "shadcn") {
return res.json(registryData)
}
- // Otherwise, serve your documentation or homepage
+ // Otherwise, serve your documentation or homepage.
res.send(htmlContent)
})
```
@@ -181,7 +241,7 @@ app.get("/", (req, res) => {
This enables:
- **Branded Registry URLs**: `shadcn add https://ui.example.com`
-- **Shorter URLs**: No need for dedicated `/r/` or `/registry/` sub-paths.
+- **Shorter URLs**: Users type your domain root, not `/r/` or `/registry/` sub-paths.
- **Easy Mnemonics**: Easier for users to remember and share your registry.
## Publish your registry
From d00605c5fb5fe3cfbcb68cea65398430cdd819f8 Mon Sep 17 00:00:00 2001
From: shadcn
Date: Mon, 20 Apr 2026 11:55:18 +0400
Subject: [PATCH 5/5] chore: changeset
---
.changeset/quiet-amber-field.md | 5 +++++
1 file changed, 5 insertions(+)
create mode 100644 .changeset/quiet-amber-field.md
diff --git a/.changeset/quiet-amber-field.md b/.changeset/quiet-amber-field.md
new file mode 100644
index 0000000000..f1cd53cabe
--- /dev/null
+++ b/.changeset/quiet-amber-field.md
@@ -0,0 +1,5 @@
+---
+"shadcn": patch
+---
+
+Send `Accept: application/vnd.shadcn.v1+json, application/json;q=0.9` and `User-Agent: shadcn` on registry fetches so servers using HTTP content negotiation can reliably serve JSON to the CLI. Fixes #10164.