<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Berger+Team | btlabs]]></title><description><![CDATA[Berger+Team | btlabs]]></description><link>https://bteam.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/6a40ca2855d3bb185709f388/4eb33a8e-f710-45ae-a596-76a0c01763b5.svg</url><title>Berger+Team | btlabs</title><link>https://bteam.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Sat, 12 Sep 2026 07:37:38 GMT</lastBuildDate><atom:link href="https://bteam.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Why I Built a Headless CMS for SMBs Instead of Using Contentful, Sanity, or WordPress]]></title><description><![CDATA[There are plenty of headless CMS options out there. Contentful, Sanity, Hygraph, Storyblok — all solid, all well-documented, all used by teams who know what they're doing. So when I started building b]]></description><link>https://bteam.hashnode.dev/why-i-built-a-headless-cms-for-smbs-instead-of-using-contentful-sanity-or-wordpress</link><guid isPermaLink="true">https://bteam.hashnode.dev/why-i-built-a-headless-cms-for-smbs-instead-of-using-contentful-sanity-or-wordpress</guid><category><![CDATA[headless cms]]></category><category><![CDATA[mcp]]></category><category><![CDATA[multilingual]]></category><category><![CDATA[privacy]]></category><category><![CDATA[AI]]></category><dc:creator><![CDATA[Florian Berger]]></dc:creator><pubDate>Sun, 28 Jun 2026 07:27:09 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a40ca2855d3bb185709f388/2b79db1e-da7d-49e9-bb3c-e808c38fa0c4.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>There are plenty of headless CMS options out there. Contentful, Sanity, Hygraph, Storyblok — all solid, all well-documented, all used by teams who know what they're doing. So when I started building btlabs Core for small and mid-sized businesses in South Tyrol (a trilingual corner of northern Italy), the obvious question is: why bother?</p>
<p>The short answer: none of them solved the actual problem my clients had. The long answer is this article.</p>
<hr />
<h2>The Problem Isn't Technology. It's the Gap Between Tech and Business Reality.</h2>
<p>Most SMBs come to me with a WordPress site that's accumulated 40+ plugins over five years. They can't update the German version without breaking the Italian one. They have a cookie banner nobody understands, a Google Analytics setup that's questionably GDPR-compliant, and a dev dependency on an agency for every content change. Their site is slow, their data is scattered, and they have zero visibility into what's actually happening.</p>
<p>The developer solution to this is usually: "let's go headless." Fair. But then you hand a business owner a Contentful account and a Next.js repo, and you've replaced one problem with three.</p>
<p>What they actually need is:</p>
<ul>
<li><p>A <strong>single source of truth</strong> for all company data</p>
</li>
<li><p><strong>True multilingual support</strong> (not a plugin bolted on top)</p>
</li>
<li><p><strong>No cookie banner</strong> — by design, not by consent management</p>
</li>
<li><p><strong>AI-readable</strong> content, not just human-readable</p>
</li>
<li><p>Self-service editing <strong>without breaking anything</strong></p>
</li>
<li><p>A way to be found by <strong>ChatGPT, Perplexity, and Claude</strong> — not just Google</p>
</li>
</ul>
<p>That last one changed everything.</p>
<hr />
<h2>The Architecture Decision: Headless + Opinionated</h2>
<p>btlabs Core is a headless platform built on <strong>TypeScript</strong>, with a structured content layer that outputs to whatever frontend the project needs. The key architectural decision was to make it <em>opinionated</em> in exactly the places where generic headless CMS tools are intentionally flexible.</p>
<h3>Content as Structured Data, Not Just Text</h3>
<p>Instead of free-form rich text everywhere, every piece of business content is modeled explicitly:</p>
<pre><code class="language-typescript">// Company identity — not just a "page"
interface CompanyIdentity {
  name: Record&lt;Lang, string&gt;
  tagline: Record&lt;Lang, string&gt;
  vision: Record&lt;Lang, string&gt;
  usps: Record&lt;Lang, string[]&gt;
  notOffered: Record&lt;Lang, string[]&gt; // 👈 This one matters more than you'd think
  openingHours: WeeklySchedule
  locations: Location[]
  schema: LocalBusinessType // 69 options: Dentist, Hotel, Plumber...
}
</code></pre>
<p>That <code>notOffered</code> field? It's one of the most important things I added. When an AI assistant is asked "does this company offer X?", it shouldn't guess. If "X" is explicitly listed as something the company doesn't do, the AI gets a clear signal. It sounds minor. It isn't.</p>
<h3>Multilingual as a First-Class Citizen</h3>
<p>South Tyrol is German, Italian, and English simultaneously. WPML and similar plugins treat multilingual as a layer on top of a monolingual system — you end up with content parity issues, broken hreflang, and editors who can't tell what's been translated and what hasn't.</p>
<p>In btlabs Core, language is part of every content type:</p>
<pre><code class="language-typescript">type Lang = 'de' | 'it' | 'en' | string // up to 6 languages

// Every translatable field is structured this way
type LocalizedString = Record&lt;Lang, string&gt;

// The admin shows translation status per field, per language
// Missing translations are surfaced — not silently filled with fallback content
</code></pre>
<p>Every URL slug is language-specific. hreflang is generated automatically. AI translation fills empty fields without touching existing content. The translation dashboard shows per-page completion status across all languages.</p>
<h3>Machine-Readable by Default</h3>
<p>This is where btlabs Core diverges most from standard CMS thinking. The content layer generates not just a website, but a full set of machine-readable outputs:</p>
<pre><code class="language-plaintext">/.well-known/llms.txt        # Primary AI discovery file
/ai.txt                      # AI bot permissions
/identity.json               # Structured company identity
/brand.json                  # Brand voice + tone
/openapi.yaml                # Auto-generated API spec
/mcp                         # MCP endpoint for AI agents
</code></pre>
<p>The <code>llms.txt</code> and <code>identity.json</code> files give AI assistants structured access to company data — services, team, pricing, FAQ, and crucially, what the company <em>doesn't</em> offer. The MCP endpoint exposes a controlled interface for AI agents to list content, search, and initiate contact — with authenticated, audited access.</p>
<pre><code class="language-typescript">// MCP tools exposed per installation
const mcpTools = [
  'list_services',
  'search_content',
  'get_opening_hours',
  'get_contact_info',
  'submit_inquiry',    // controlled, not open
] as const
</code></pre>
<p>This isn't "add an AI chatbot." It's making the business itself queryable from any AI-native interface.</p>
<hr />
<h2>The Features That Would Be Plugins Elsewhere</h2>
<p>One of the core design principles: <strong>nothing that matters should be a plugin dependency</strong>.</p>
<table>
<thead>
<tr>
<th>Feature</th>
<th>WordPress approach</th>
<th>btlabs Core</th>
</tr>
</thead>
<tbody><tr>
<td>SEO meta</td>
<td>Yoast / RankMath plugin</td>
<td>Built-in, per language</td>
</tr>
<tr>
<td>Multilingual</td>
<td>WPML / Polylang</td>
<td>Native content model</td>
</tr>
<tr>
<td>Cookieless stats</td>
<td>Add Matomo/Plausible</td>
<td>Self-hosted Umami, built-in</td>
</tr>
<tr>
<td>Structured data</td>
<td>Schema Pro plugin</td>
<td>Auto-generated from content model</td>
</tr>
<tr>
<td>Form handling</td>
<td>Contact Form 7 + plugin</td>
<td>Built-in with inbox + double opt-in</td>
</tr>
<tr>
<td>Backup</td>
<td>UpdraftPlus plugin</td>
<td>3-2-1 rule, GPG-encrypted, built-in</td>
</tr>
<tr>
<td>AI content</td>
<td>Any third-party plugin</td>
<td>Integrated, key-optional</td>
</tr>
<tr>
<td>MCP endpoint</td>
<td>Not available</td>
<td>Built-in</td>
</tr>
</tbody></table>
<p>The <a href="https://www.wordfence.com/blog/2025/02/2024-wordpress-security-year-in-review/">2024 Wordfence Annual Report</a> found that 96% of vulnerabilities in WordPress installations were in plugins, not core. Reducing plugin surface area isn't just cleaner architecture — it's a security decision.</p>
<hr />
<h2>The Privacy Architecture</h2>
<p>No cookie banner is a product decision, not just a compliance checkbox. The path to get there:</p>
<p><strong>Analytics:</strong> Self-hosted <a href="https://umami.is/">Umami</a> — cookieless, EU-hosted, no third-party data sharing. No consent needed for analytics in most cases (verify with your legal team — this isn't legal advice).</p>
<p><strong>IP handling:</strong> Daily one-way hash. Not reversible, not stored long-term.</p>
<p><strong>EXIF stripping:</strong> All uploaded images have metadata stripped automatically.</p>
<p><strong>Data retention:</strong> Configurable per data type (activity logs: 90 days, form submissions: 180 days, etc.)</p>
<p><strong>AI training opt-out:</strong> TDM (Text and Data Mining) opt-out per EU AI Act included in robots.txt and ai.txt.</p>
<p><strong>EU hosting:</strong> Every installation runs on a dedicated server (not shared infrastructure) in the EU. The business owns the data — not us, not a US cloud provider.</p>
<pre><code class="language-nginx"># ai.txt example output
User-agent: *
Disallow: /admin/
Allow: /llms.txt
Allow: /identity.json
Allow: /brand.json

TDM-Reservation: 1
</code></pre>
<hr />
<h2>The MCP Integration</h2>
<p>This is the part I'm most interested in long-term. The MCP (Model Context Protocol) endpoint lets AI agents interact with the CMS directly — not through scraping, but through an authenticated, schema-defined interface.</p>
<p>What this means practically:</p>
<pre><code class="language-plaintext"># A business owner can say to Claude or ChatGPT:
"Update the opening hours for the Bozen location to add Saturday 9-13."

# The agent calls the MCP endpoint:
POST /mcp/update_opening_hours
Authorization: Bearer &lt;mcp-key&gt;
{
  "location": "bozen",
  "day": "saturday",
  "open": "09:00",
  "close": "13:00"
}
</code></pre>
<p>No admin UI required. The MCP keys have scoped permissions — an agent can read everything but only write to specific content types. Every action is logged. The audit trail is queryable.</p>
<p>This isn't theoretical. The AI agent tracking built into the platform shows which AI crawlers are hitting the site, how often, and what they're querying (topic-level only — not raw content). Googlebot, GPTBot, ClaudeBot, PerplexityBot — all tracked, all distinguished by verified IP ranges.</p>
<hr />
<h2>Honest Trade-offs</h2>
<p>I'd rather say this clearly than have someone discover it the hard way:</p>
<p><strong>btlabs Core is not a SaaS product you can sign up for.</strong> It's a platform we deploy for clients. The codebase isn't open source. This means you're trusting us as the team. We've documented the migration path (the content model is exportable, structured data is open formats), but the dependency is real.</p>
<p><strong>It's not the right fit if:</strong></p>
<ul>
<li><p>You want a simple single-page site with no strategic intent</p>
</li>
<li><p>Your team won't maintain content (the system helps, but it doesn't replace editorial discipline)</p>
</li>
<li><p>You need a plugin ecosystem (there isn't one — everything is built-in or not available)</p>
</li>
<li><p>You need guaranteed AI citations or ranking positions (nobody can promise that)</p>
</li>
</ul>
<p><strong>It works well when:</strong></p>
<ul>
<li><p>You're managing a multilingual business with real content maintenance needs</p>
</li>
<li><p>You want your site to be AI-native, not AI-adjacent</p>
</li>
<li><p>Plugin maintenance and security patching are genuine pain points</p>
</li>
<li><p>You want to own your data and your infrastructure</p>
</li>
</ul>
<hr />
<h2>The Stack, for the Curious</h2>
<ul>
<li><p><strong>Backend/CMS:</strong> TypeScript, custom content layer</p>
</li>
<li><p><strong>Frontend:</strong> Next.js (ISR for sub-200ms delivery)</p>
</li>
<li><p><strong>Web server:</strong> Caddy (automatic HTTPS, reverse proxy)</p>
</li>
<li><p><strong>Analytics:</strong> Self-hosted Umami</p>
</li>
<li><p><strong>Email:</strong> Resend or SMTP (configurable)</p>
</li>
<li><p><strong>Auth:</strong> 2FA (TOTP + recovery codes), brute-force protection, rate limiting (token bucket per IP)</p>
</li>
<li><p><strong>Security:</strong> AES-256 for stored credentials, CSP headers, fail2ban, automated CVE scanning</p>
</li>
<li><p><strong>Backups:</strong> GPG-encrypted, 3-2-1 principle, 60-day retention, restore drills</p>
</li>
<li><p><strong>AI models:</strong> Pluggable (Claude, Gemini, DeepSeek — key optional, all features work without one)</p>
</li>
<li><p><strong>Hosting:</strong> Dedicated EU server per client</p>
</li>
</ul>
<hr />
<h2>Where This Goes Next</h2>
<p>The MCP endpoint is the piece I think has the most interesting long-term implications. Right now it's read-heavy — AI agents can query and contact. The roadmap includes structured booking, quote requests, and eventually transactional actions (within defined scope and with explicit user confirmation).</p>
<p>The broader thesis: a business website shouldn't just be a place people visit. It should be a <strong>queryable business entity</strong> — readable by humans through a browser, by search engines through structured data, and by AI agents through a defined interface. The technology to do this exists now. Most SMBs just don't have it yet.</p>
<hr />
<p><strong>btlabs Core</strong> → <a href="https://btlabs.dev">btlabs.dev</a></p>
<p><em>Built by Berger+Team, a freelance collective based in Bozen/South Tyrol. We've been doing this since 2018. Happy to answer technical questions in the comments.</em></p>
<hr />
<p><em>Tags: #headlesscms #typescript #webdev #ai #mcp #multilingual #seo #dsgvo</em></p>
]]></content:encoded></item></channel></rss>