Pattern 2: Header-Scoped Sites (X-Site-Id)
Manage multiple client websites, regional portals, or sub-brands from a single Dyrected backend using the X-Site-Id header.
When you operate a single Dyrected deployment that powers multiple distinct client websites, regional portals, or sub-brands, you often need separate collections or globals per site alongside shared content.
Dyrected enables this with the siteId property on collections and globals, paired with the X-Site-Id HTTP header and server-side anti-spoofing verification.
Mental Model
In header-scoped multi-tenancy:
- Site-Specific Collections & Globals: Tagged with
siteId: "site-a". Only accessible when requests provideX-Site-Id: site-a. - Shared Collections & Globals: Omit
siteId. Accessible across all sites. - Client Requests: Frontend applications or API consumers send the
X-Site-IdHTTP header to declare their target site. - Security & Anti-Spoofing: Authenticated users have an
allowedSiteslist. If a user attempts to send anX-Site-Idthey are not permitted to access, Dyrected immediately rejects the request with403 Forbidden.
Complete Configuration Example
Here is a complete, copy-pasteable dyrected.config.ts defining shared content, site-specific collections, site-specific globals, and an authentication collection with anti-spoofing rules:
import {
defineConfig,
defineEmailField,
defineJsonField,
defineTextField,
defineRichTextField,
} from "@dyrected/core";
export default defineConfig({
collections: [
// 1. Auth collection with allowedSites anti-spoofing validation
{
slug: "users",
auth: true,
fields: [
defineEmailField({
name: "email",
label: "Email Address",
required: true,
}),
defineJsonField({
name: "allowedSites", // e.g. ["brand-us", "brand-eu"]
label: "Allowed Sites",
required: true,
defaultValue: ["brand-us"],
}),
],
},
// 2. Shared collection accessible by all sites
{
slug: "shared-faqs",
fields: [
defineTextField({
name: "question",
label: "FAQ Question",
required: true,
}),
defineRichTextField({
name: "answer",
label: "FAQ Answer",
required: true,
}),
],
},
// 3. Articles collection exclusive to Brand US
{
slug: "articles",
siteId: "brand-us",
fields: [
defineTextField({
name: "title",
label: "Article Title",
required: true,
}),
defineTextField({
name: "usHeadline",
label: "US Regional Headline",
}),
],
},
// 4. Articles collection exclusive to Brand EU
{
slug: "articles",
siteId: "brand-eu",
fields: [
defineTextField({
name: "title",
label: "Article Title",
required: true,
}),
defineTextField({
name: "euVatDisclaimer",
label: "EU VAT Disclaimer",
}),
],
},
],
globals: [
// Site-specific navigation global for Brand US
{
slug: "site-navigation",
siteId: "brand-us",
fields: [
defineTextField({
name: "logoUrl",
label: "Brand Logo URL",
}),
defineTextField({
name: "tollFreePhone",
label: "US Toll-Free Support Phone",
}),
],
},
// Site-specific navigation global for Brand EU
{
slug: "site-navigation",
siteId: "brand-eu",
fields: [
defineTextField({
name: "logoUrl",
label: "Brand Logo URL",
}),
defineEmailField({
name: "gdprContactEmail",
label: "GDPR Privacy Contact Email",
}),
],
},
],
});Anti-Spoofing & Security Verification
Dyrected enforces strict site authorization checks across all dynamic REST endpoints and /api/schemas:
1. Authorized Site Access
When an editor with allowedSites: ["brand-us"] sends X-Site-Id: brand-us:
/api/schemasreturns onlyshared-faqs,articles(forbrand-us), and thesite-navigationglobal forbrand-us./api/collections/articlesqueries Brand US articles.
2. Header Spoofing Rejection (403 Forbidden)
If the same user attempts to send X-Site-Id: brand-eu:
- Dyrected's
resolveAuthorizedSiteIdmiddleware compares the header against the authenticated user'sallowedSites. - The request is immediately rejected with
403 Forbidden:
{
"error": true,
"message": "Forbidden: User is not authorized to access site \"brand-eu\"."
}3. Collection Isolation (404 Not Found)
If an unauthenticated request (or a consumer without access) tries to query /api/collections/articles with X-Site-Id: brand-eu, but is only permitted for brand-us:
- Dyrected returns
404 Not Foundrather than leaking information about collections belonging to other sites.
Client & SDK Integration
Using @dyrected/sdk
When configuring the @dyrected/sdk client in your frontend application (e.g. Next.js, Remix, Astro, or Vue), specify the headers option:
import { createClient } from "@dyrected/sdk";
// Create client configured for Brand US
export const cms = createClient({
apiUrl: process.env.NEXT_PUBLIC_CMS_URL || "https://cms.example.com",
headers: {
"X-Site-Id": process.env.NEXT_PUBLIC_SITE_ID || "brand-us",
},
});
async function loadHomePageContent() {
// Queries articles belonging to Brand US
const articles = await cms.collection("articles").find({ limit: 5 });
// Reads the site-navigation global for Brand US
const navigation = await cms.global("site-navigation").get();
console.log("Articles:", articles.docs);
console.log("Navigation:", navigation);
}
loadHomePageContent();Using Standard Fetch
async function fetchBrandContent() {
const response = await fetch("https://cms.example.com/api/collections/articles", {
headers: {
"Authorization": `Bearer ${process.env.CMS_TOKEN}`,
"X-Site-Id": "brand-us",
"Content-Type": "application/json",
},
});
const data = await response.json();
return data.docs;
}AI Content Assistant & Multi-Site Scoping
When using the AI Content Assistant with header-scoped multi-tenancy:
- Context Initialization: The AI agent initializes using the resolved
siteIdfor the current editor. - Schema Visibility: The AI can only inspect collection schemas and globals assigned to the active
siteId(plus shared ones). - RAG Vector Scoping: Semantic search (
searchContent) only searches text chunks tagged with the activeprojectId/siteId. - Action Approval: Proposals (
proposeCreateDocument,proposeUpdateGlobal) record the targetprojectId. At execution time, Dyrected verifies that the approving user has permission for that site before applying any database changes.