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.