DEV Community

Ugur Aslim
Ugur Aslim

Posted on • Originally published at uguraslim.com

Astro Content Collections for Multi-Tenant Help Docs: Rendering Tenant-Specific Documentation Without CMS Sprawl

Astro Content Collections for Multi-Tenant Help Docs: Rendering Tenant-Specific Documentation Without CMS Sprawl

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.

Here'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.

I 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.

Why Static Docs Beat Headless CMS for Help Centers

When you use a headless CMS for documentation:

  • You pay per seat, per API call, or per webhook
  • You maintain another service, another auth system, another database
  • Your content lives outside your Git history (ask me how fun that is during audits)
  • You add latency: request → CMS API → render → ship
  • You can't code-review documentation changes

Static 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.

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.

Setting Up Tenant-Gated Documentation

First, define your content schema with feature-level metadata:

// src/content/config.ts
import { defineCollection, z } from 'astro:content';

const docsCollection = defineCollection({
  type: 'content',
  schema: z.object({
    title: z.string(),
    description: z.string(),
    // Tier requirement: 'free', 'pro', 'enterprise'
    minTier: z.enum(['free', 'pro', 'enterprise']).default('free'),
    // Feature flags—a doc can require multiple features
    requiredFeatures: z.array(z.string()).default([]),
    // Category for navigation
    category: z.enum(['getting-started', 'ai-features', 'billing', 'api']),
    // Publication status
    draft: z.boolean().default(false),
    lastUpdated: z.date().optional(),
  }),
});

export const collections = {
  docs: docsCollection,
};
Enter fullscreen mode Exit fullscreen mode

Now, structure your content directory with tenant-aware naming:

src/content/docs/
├── getting-started/
│   ├── setup.md (minTier: free)
│   └── authentication.md (minTier: free)
├── ai-features/
│   ├── text-summarization.md (minTier: free, requiredFeatures: [summarization])
│   ├── sentiment-analysis.md (minTier: pro, requiredFeatures: [sentiment])
│   └── custom-models.md (minTier: enterprise, requiredFeatures: [custom-models])
├── billing/
│   ├── plans.md (minTier: free)
│   └── enterprise-sso.md (minTier: enterprise)
└── api/
    └── reference.md (minTier: pro)
Enter fullscreen mode Exit fullscreen mode

Here's a concrete doc file:

---
title: "Sentiment Analysis API"
description: "Analyze emotion and intent in user input"
minTier: "pro"
requiredFeatures: ["sentiment-analysis", "api-access"]
category: "ai-features"
lastUpdated: 2025-01-15
---

# Sentiment Analysis

Our sentiment engine classifies text into positive, negative, neutral, and mixed.

## Endpoint

Enter fullscreen mode Exit fullscreen mode

POST /api/v1/sentiment
Authorization: Bearer YOUR_API_KEY


## Response

Enter fullscreen mode Exit fullscreen mode


json
{
"score": 0.87,
"label": "positive",
"confidence": 0.94
}

Enter fullscreen mode Exit fullscreen mode


typescript

Building Tenant-Specific Static Sites

The magic happens in your Astro pages. You filter collections by tenant tier and features:

// src/pages/docs/[tenant]/[...slug].astro
import { getCollection } from 'astro:content';
import type { GetStaticPaths } from 'astro';

// Your tenant configuration (from database, config file, etc.)
const TENANTS = {
  acme_free: { tier: 'free', features: ['summarization'] },
  acme_pro: { tier: 'pro', features: ['summarization', 'sentiment-analysis', 'api-access'] },
  globex_enterprise: { 
    tier: 'enterprise', 
    features: ['summarization', 'sentiment-analysis', 'custom-models', 'api-access', 'sso'] 
  },
};

export const getStaticPaths: GetStaticPaths = async () => {
  const allDocs = await getCollection('docs');
  const paths = [];

  // Generate a static page for every doc + tenant combination
  for (const [tenantId, config] of Object.entries(TENANTS)) {
    const visibleDocs = allDocs.filter((doc) => {
      // Check tier access
      const tierHierarchy = { free: 0, pro: 1, enterprise: 2 };
      if (tierHierarchy[config.tier] < tierHierarchy[doc.data.minTier]) {
        return false;
      }

      // Check feature access
      if (doc.data.requiredFeatures.length > 0) {
        const hasAllFeatures = doc.data.requiredFeatures.every((feature) =>
          config.features.includes(feature)
        );
        if (!hasAllFeatures) return false;
      }

      // Exclude drafts
      if (doc.data.draft) return false;

      return true;
    });

    // Create routes
    for (const doc of visibleDocs) {
      paths.push({
        params: { tenant: tenantId, slug: doc.slug },
        props: { doc, tenantId, config },
      });
    }
  }

  return paths;
};

interface Props {
  doc: any;
  tenantId: string;
  config: any;
}

const { doc, tenantId, config } = Astro.props;
const { Content } = await doc.render();
---

<html>
  <head>
    <title>{doc.data.title}</title>
  </head>
  <body>
    <nav>
      <p>Tenant: {tenantId} | Tier: {config.tier}</p>
    </nav>
    <article>
      <h1>{doc.data.title}</h1>
      <Content />
    </article>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

This 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), and the HTML is precomputed.

The Real Win: Updates Ship as Git Commits

You've got a new AI feature? Add a Markdown file, set minTier: pro, 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.

# .github/workflows/docs.yml
name: Deploy Docs
on:
  push:
    branches: [main]
    paths: ["src/content/docs/**"]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
      - run: npm install && npm run build
      - uses: cloudflare/pages-action@v1
        with:
          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
Enter fullscreen mode Exit fullscreen mode

Gotcha: Search Indexing Across Tenants

Here's what burned me: if you're generating /docs/acme_pro/ and /docs/globex_enterprise/ separately, search engines see duplicate content. The same help doc appears at multiple URLs.

Solution: Add a robots meta tag to tenant-specific builds:

// In your page component
const isProductionTenant = tenantId === 'public'; // or check env
const robotsContent = isProductionTenant ? 'index, follow' : 'noindex, follow';
---

<meta name="robots" content={robotsContent} />
Enter fullscreen mode Exit fullscreen mode

Or use canonical tags and serve the "public" docs at /docs/ with all features visible, then tenant-specific sites as internal-only.

The second gotcha: versioning. If you have API docs that change between versions, your content schema needs a version field. Build tenant-specific and version-specific paths. This scales quickly—think it through before you ship.

When to Skip This Approach

If 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.

Ship less infrastructure. Version your docs in Git. Let Astro compile.

Top comments (0)