{"slug": "astro-content-collections-for-multi-tenant-help-docs-rendering-tenant-specific", "title": "Astro Content Collections for Multi-Tenant Help Docs: Rendering Tenant-Specific Documentation Without CMS Sprawl", "summary": "A developer built a multi-tenant documentation system for CitizenApp using Astro Content Collections, replacing a headless CMS with static Markdown files versioned in Git. The system generates tenant-specific help docs at build time by filtering content based on feature gates and tier requirements, eliminating the need for CMS API calls or separate documentation sites. The approach enforces schema validation through Astro's Content Collections and handles routing boilerplate, producing fast, cacheable HTML files that are auditable through the repository.", "body_md": "I've watched teams burn money on Contentful, Strapi, and Sanity licenses just to manage help documentation that differs slightly between billing tiers. A startup pays $500/month for a headless CMS when they could ship the same thing as static files versioned in Git.\n\nHere's the hard truth: **most SaaS help docs don't need a CMS**. They need Git, a build pipeline, and metadata. If you're using Astro already (and you should be for marketing sites), Content Collections gives you a native, zero-overhead way to generate tenant-specific documentation at build time.\n\nI built this for CitizenApp. We have 9 AI features split across three tiers. Each tenant sees only the docs for features they've paid for. Instead of querying a CMS API on every request or maintaining separate documentation sites, we define docs as Markdown, tag them with feature gates, and Astro handles the rest during the static build.\n\nWhen you use a headless CMS for documentation:\n\nStatic generation eliminates all of this. Your docs compile at build time. They're just HTML files. They're fast, cacheable, and auditable because they're in your repo.\n\n**I prefer Astro's Content Collections over rolling my own because it enforces schema validation and handles the routing boilerplate.** Without it, you're writing custom glob patterns and Zod schemas. With it, it's declarative.\n\nFirst, define your content schema with feature-level metadata:\n\n``` js\n// src/content/config.ts\nimport { defineCollection, z } from 'astro:content';\n\nconst docsCollection = defineCollection({\n  type: 'content',\n  schema: z.object({\n    title: z.string(),\n    description: z.string(),\n    // Tier requirement: 'free', 'pro', 'enterprise'\n    minTier: z.enum(['free', 'pro', 'enterprise']).default('free'),\n    // Feature flags—a doc can require multiple features\n    requiredFeatures: z.array(z.string()).default([]),\n    // Category for navigation\n    category: z.enum(['getting-started', 'ai-features', 'billing', 'api']),\n    // Publication status\n    draft: z.boolean().default(false),\n    lastUpdated: z.date().optional(),\n  }),\n});\n\nexport const collections = {\n  docs: docsCollection,\n};\n```\n\nNow, structure your content directory with tenant-aware naming:\n\n```\nsrc/content/docs/\n├── getting-started/\n│   ├── setup.md (minTier: free)\n│   └── authentication.md (minTier: free)\n├── ai-features/\n│   ├── text-summarization.md (minTier: free, requiredFeatures: [summarization])\n│   ├── sentiment-analysis.md (minTier: pro, requiredFeatures: [sentiment])\n│   └── custom-models.md (minTier: enterprise, requiredFeatures: [custom-models])\n├── billing/\n│   ├── plans.md (minTier: free)\n│   └── enterprise-sso.md (minTier: enterprise)\n└── api/\n    └── reference.md (minTier: pro)\n```\n\nHere's a concrete doc file:\n\n```\n---\ntitle: \"Sentiment Analysis API\"\ndescription: \"Analyze emotion and intent in user input\"\nminTier: \"pro\"\nrequiredFeatures: [\"sentiment-analysis\", \"api-access\"]\ncategory: \"ai-features\"\nlastUpdated: 2025-01-15\n---\n\n# Sentiment Analysis\n\nOur sentiment engine classifies text into positive, negative, neutral, and mixed.\n\n## Endpoint\n```\n\nPOST /api/v1/sentiment\n\nAuthorization: Bearer YOUR_API_KEY\n\n```\n## Response\n```\n\njson\n\n{\n\n\"score\": 0.87,\n\n\"label\": \"positive\",\n\n\"confidence\": 0.94\n\n}\n\ntypescript\n\nThe magic happens in your Astro pages. You filter collections by tenant tier and features:\n\n``` js\n// src/pages/docs/[tenant]/[...slug].astro\nimport { getCollection } from 'astro:content';\nimport type { GetStaticPaths } from 'astro';\n\n// Your tenant configuration (from database, config file, etc.)\nconst TENANTS = {\n  acme_free: { tier: 'free', features: ['summarization'] },\n  acme_pro: { tier: 'pro', features: ['summarization', 'sentiment-analysis', 'api-access'] },\n  globex_enterprise: { \n    tier: 'enterprise', \n    features: ['summarization', 'sentiment-analysis', 'custom-models', 'api-access', 'sso'] \n  },\n};\n\nexport const getStaticPaths: GetStaticPaths = async () => {\n  const allDocs = await getCollection('docs');\n  const paths = [];\n\n  // Generate a static page for every doc + tenant combination\n  for (const [tenantId, config] of Object.entries(TENANTS)) {\n    const visibleDocs = allDocs.filter((doc) => {\n      // Check tier access\n      const tierHierarchy = { free: 0, pro: 1, enterprise: 2 };\n      if (tierHierarchy[config.tier] < tierHierarchy[doc.data.minTier]) {\n        return false;\n      }\n\n      // Check feature access\n      if (doc.data.requiredFeatures.length > 0) {\n        const hasAllFeatures = doc.data.requiredFeatures.every((feature) =>\n          config.features.includes(feature)\n        );\n        if (!hasAllFeatures) return false;\n      }\n\n      // Exclude drafts\n      if (doc.data.draft) return false;\n\n      return true;\n    });\n\n    // Create routes\n    for (const doc of visibleDocs) {\n      paths.push({\n        params: { tenant: tenantId, slug: doc.slug },\n        props: { doc, tenantId, config },\n      });\n    }\n  }\n\n  return paths;\n};\n\ninterface Props {\n  doc: any;\n  tenantId: string;\n  config: any;\n}\n\nconst { doc, tenantId, config } = Astro.props;\nconst { Content } = await doc.render();\n---\n\n<html>\n  <head>\n    <title>{doc.data.title}</title>\n  </head>\n  <body>\n    <nav>\n      <p>Tenant: {tenantId} | Tier: {config.tier}</p>\n    </nav>\n    <article>\n      <h1>{doc.data.title}</h1>\n      <Content />\n    </article>\n  </body>\n</html>\n```\n\nThis generates *N docs × M tenants* static HTML files at build time. Deploy to Cloudflare Pages or Vercel. Each tenant gets their own URL namespace (`/docs/acme_pro/ai-features/sentiment-analysis`\n\n), and the HTML is precomputed.\n\nYou've got a new AI feature? Add a Markdown file, set `minTier: pro`\n\n, and commit. GitHub Actions builds and deploys in seconds. No CMS UI to configure, no API calls during rendering, no cache invalidation headers to debug.\n\n```\n# .github/workflows/docs.yml\nname: Deploy Docs\non:\n  push:\n    branches: [main]\n    paths: [\"src/content/docs/**\"]\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: actions/setup-node@v4\n      - run: npm install && npm run build\n      - uses: cloudflare/pages-action@v1\n        with:\n          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}\n```\n\nHere's what burned me: if you're generating `/docs/acme_pro/`\n\nand `/docs/globex_enterprise/`\n\nseparately, search engines see duplicate content. The same help doc appears at multiple URLs.\n\n**Solution:** Add a `robots`\n\nmeta tag to tenant-specific builds:\n\n``` js\n// In your page component\nconst isProductionTenant = tenantId === 'public'; // or check env\nconst robotsContent = isProductionTenant ? 'index, follow' : 'noindex, follow';\n---\n\n<meta name=\"robots\" content={robotsContent} />\n```\n\nOr use canonical tags and serve the \"public\" docs at `/docs/`\n\nwith all features visible, then tenant-specific sites as internal-only.\n\nThe second gotcha: **versioning**. If you have API docs that change between versions, your content schema needs a `version`\n\nfield. Build tenant-specific *and* version-specific paths. This scales quickly—think it through before you ship.\n\nIf your docs change hourly (unlikely) or require real-time user input (comments, feedback forms), add a separate lightweight service. But for static help content? This is unbeatable.\n\nShip less infrastructure. Version your docs in Git. Let Astro compile.", "url": "https://wpnews.pro/news/astro-content-collections-for-multi-tenant-help-docs-rendering-tenant-specific", "canonical_source": "https://dev.to/uaslimcreate/astro-content-collections-for-multi-tenant-help-docs-rendering-tenant-specific-documentation-l58", "published_at": "2026-06-05 07:19:11+00:00", "updated_at": "2026-06-05 07:42:18.648876+00:00", "lang": "en", "topics": ["ai-products", "ai-tools", "ai-infrastructure", "ai-startups", "mlops"], "entities": ["Contentful", "Strapi", "Sanity", "Astro", "CitizenApp", "Git"], "alternates": {"html": "https://wpnews.pro/news/astro-content-collections-for-multi-tenant-help-docs-rendering-tenant-specific", "markdown": "https://wpnews.pro/news/astro-content-collections-for-multi-tenant-help-docs-rendering-tenant-specific.md", "text": "https://wpnews.pro/news/astro-content-collections-for-multi-tenant-help-docs-rendering-tenant-specific.txt", "jsonld": "https://wpnews.pro/news/astro-content-collections-for-multi-tenant-help-docs-rendering-tenant-specific.jsonld"}}