Blogger as a Headless CMS: Architecture and Implementation
Blogger's JSON API enables headless content delivery without server infrastructure. The platform stores posts and pages. Client applications fetch structured data via public endpoints. This eliminates hosting costs while retaining a managed editorial interface.
Core Mechanism
Blogger exposes every blog as a JSON resource. Append ?alt=json to any blog URL or use the dedicated API endpoint. The response contains posts, pages, labels, and metadata in a parseable format. No authentication required for public blogs.
The API returns up to 500 posts per request with pagination via nextPageToken. Published and draft states separate at the endpoint level. Labels function as taxonomies. Custom permalinks map to stable post.id values.
API Endpoint Structure
All requests target https://www.blogger.com/feeds/{blogId}/posts/default with query parameters controlling output format and scope.
| Parameter | Function | Example |
|---|---|---|
alt=json |
Returns JSON instead of Atom XML | ?alt=json |
max-results |
Posts per page (max 500) | &max-results=100 |
start-index |
Offset for pagination | &start-index=101 |
published-min |
Filter by date (ISO 8601) | &published-min=2026-01-01 |
category |
Filter by label | &category=API |
q |
Full-text search | &q=headless |
path |
Single post by URL path | &path=/2026/06/post-slug.html |
Retrieve the blogId from Blogger settings or extract it from the default feed URL. The numeric ID remains constant across domain changes.
Fetching Posts: Implementation Patterns
Basic Fetch with JavaScript
const BLOG_ID = '1234567890123456789';
const API_URL = `https://www.blogger.com/feeds/${BLOG_ID}/posts/default?alt=json&max-results=500`;
async function fetchPosts() {
const response = await fetch(API_URL);
const data = await response.json();
return data.feed.entry.map(entry => ({
id: entry.id.$t.split('post-')[1],
title: entry.title.$t,
content: entry.content.$t,
published: entry.published.$t,
updated: entry.updated.$t,
labels: entry.category?.map(c => c.term) || [],
url: entry.link.find(l => l.rel === 'alternate')?.href,
images: extractImages(entry.content.$t)
}));
}
function extractImages(html) {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
return [...doc.querySelectorAll('img')].map(img => ({
src: img.src,
alt: img.alt
}));
}
Server-Side Fetch with Node.js
// Netlify/Vercel function
exports.handler = async () => {
const BLOG_ID = process.env.BLOGGER_ID;
const response = await fetch(
`https://www.blogger.com/feeds/${BLOG_ID}/posts/default?alt=json&max-results=500`,
{ headers: { 'Accept': 'application/json' } }
);
const data = await response.json();
const posts = data.feed.entry?.map(normalizePost) || [];
return {
statusCode: 200,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ posts, total: posts.length })
};
};
function normalizePost(entry) {
return {
slug: entry.link
.find(l => l.rel === 'alternate')
?.href
.split('/')
.pop()
.replace('.html', ''),
title: entry.title.$t,
html: entry.content.$t,
excerpt: entry.summary?.$t || '',
published: entry.published.$t,
modified: entry.updated.$t,
categories: entry.category?.map(c => c.term) || [],
author: {
name: entry.author[0].name.$t,
uri: entry.author[0].uri?.$t
}
};
}
Single Post Retrieval by Slug
The API lacks direct slug lookup. Implement path-based resolution using the path parameter or client-side filtering.
async function getPostBySlug(slug) {
// Method 1: Direct path query
const pathQuery = `https://www.blogger.com/feeds/${BLOG_ID}/posts/default` +
`?alt=json&path=/2026/06/${slug}.html`;
// Method 2: Client filter (requires all posts)
const allPosts = await fetchPosts();
return allPosts.find(p => p.slug === slug);
}
Method 1 requires exact path knowledge. Method 2 consumes more bandwidth but enables flexible matching. Cache the full post list to reduce API calls.
Label-Based Taxonomy System
Blogger labels function as categories and tags. Query filtered feeds for taxonomy-driven navigation.
async function fetchByLabel(label, maxResults = 50) {
const url = new URL(
`https://www.blogger.com/feeds/${BLOG_ID}/posts/default`
);
url.searchParams.set('alt', 'json');
url.searchParams.set('max-results', maxResults);
url.searchParams.set('category', label);
const response = await fetch(url);
const data = await response.json();
return data.feed.entry || [];
}
// Usage: fetchByLabel('api-tutorial')
Multiple labels create AND conditions. OR logic requires client-side merging of separate requests.
Content Transformation Pipeline
Raw HTML from Blogger requires processing before consumption by modern frameworks.
Santization and Optimization
import DOMPurify from 'dompurify';
import { JSDOM } from 'jsdom';
function transformContent(html, options = {}) {
const dom = new JSDOM(html);
const doc = dom.window.document;
// Remove Blogger-specific attributes
doc.querySelectorAll('[id^="post-body-"]').forEach(el => {
el.removeAttribute('id');
});
// Convert internal links to relative paths
doc.querySelectorAll('a[href*="doxlayer.blogspot.com"]').forEach(a => {
a.href = a.href.replace('https://doxlayer.blogspot.com', '');
});
// Lazy-load images
doc.querySelectorAll('img').forEach(img => {
img.loading = 'lazy';
img.decoding = 'async';
// Responsive srcset if original available
if (img.src.includes('bp.blogspot.com')) {
const base = img.src.replace(/s\d+/, 's{w}');
img.srcset = [320, 640, 960, 1280]
.map(w => `${base.replace('{w}', w)} ${w}w`)
.join(', ');
}
});
return {
html: DOMPurify.sanitize(doc.body.innerHTML),
text: doc.body.textContent.trim(),
wordCount: doc.body.textContent.split(/\s+/).length,
readingTime: Math.ceil(doc.body.textContent.split(/\s+/).length / 200)
};
}
Static Site Generation Integration
Eleventy (11ty) Data File
// _data/blogger.js
const Cache = require('@11ty/eleventy-fetch');
module.exports = async function() {
const BLOG_ID = process.env.BLOGGER_ID;
const data = await Cache(
`https://www.blogger.com/feeds/${BLOG_ID}/posts/default?alt=json&max-results=500`,
{
duration: '1h',
type: 'json',
fetchOptions: {
headers: { 'Accept': 'application/json' }
}
}
);
return data.feed.entry.map(entry => ({
title: entry.title.$t,
slug: entry.title.$t
.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-'),
date: new Date(entry.published.$t),
content: entry.content.$t,
excerpt: entry.summary?.$t?.substring(0, 200) + '...',
tags: entry.category?.map(c => c.term) || []
}));
};
Astro Integration
// src/lib/blogger.js
export async function getBloggerPosts() {
const response = await fetch(
`https://www.blogger.com/feeds/${import.meta.env.BLOGGER_ID}/posts/default?alt=json&max-results=500`
);
const data = await response.json();
return data.feed.entry?.map(entry => ({
id: entry.id.$t,
title: entry.title.$t,
body: entry.content.$t,
pubDate: new Date(entry.published.$t),
updated: new Date(entry.updated.$t),
categories: entry.category?.map(c => c.term) || [],
// Generate Astro-compatible slug
slug: entry.link
.find(l => l.rel === 'alternate')
?.href
.match(/\/([^/]+)\.html$/)[1]
})) || [];
}
// src/pages/blog/[slug].astro
---
export async function getStaticPaths() {
const posts = await getBloggerPosts();
return posts.map(post => ({
params: { slug: post.slug },
props: { post }
}));
}
const { post } = Astro.props;
---
<article set:html={post.body} />
Search Implementation
Blogger's built-in search parameter has limitations. Implement client-side search with pre-built indexes for better performance.
// Build search index at build time
function buildSearchIndex(posts) {
const flexSearch = require('flexsearch');
const index = new flexSearch.Document({
document: {
id: 'id',
index: ['title', 'content', 'categories']
},
tokenize: 'forward'
});
posts.forEach(post => {
index.add({
id: post.id,
title: post.title,
content: post.textContent,
categories: post.categories.join(' ')
});
});
return index;
}
// Client search with debouncing
function searchPosts(query, indexData) {
const index = flexSearch.Document.load(indexData);
const results = index.search(query, {
enrich: true,
limit: 10
});
return results.flatMap(r => r.result);
}
Caching and Performance
| Strategy | Implementation | Invalidation |
|---|---|---|
| Build-time fetch | SSG frameworks pull at build | Rebuild on webhook |
| Edge cache | Cloudflare/Vercel Cache-Control | API revalidation |
| Stale-while-revalidate | Next.js revalidate |
Background refresh |
| Client cache | IndexedDB with timestamp | ETag comparison |
// Next.js revalidation pattern
export async function getStaticProps() {
const posts = await fetchBloggerPosts();
return {
props: { posts },
revalidate: 60 // Regenerate every 60 seconds
};
}
// Webhook handler for instant invalidation
export default async function handler(req, res) {
if (req.headers['x-blogger-token'] !== process.env.WEBHOOK_SECRET) {
return res.status(401).end();
}
await res.revalidate('/blog');
res.status(200).json({ revalidated: true });
}
Image Handling
Blogger serves images through a resizer proxy. Manipulate dimensions via URL parameters.
function getOptimizedImageUrl(originalUrl, { width, height, crop } = {}) {
const url = new URL(originalUrl);
// Replace size parameter
let size = `s${width}`;
if (height) size += `-h${height}`;
if (crop) size += '-c';
url.pathname = url.pathname.replace(/s\d+[^/]*/, size);
return url.toString();
}
// Generate srcset for responsive images
function generateSrcset(baseUrl) {
const widths = [320, 640, 960, 1280, 1920];
return widths
.map(w => `${getOptimizedImageUrl(baseUrl, { width: w })} ${w}w`)
.join(', ');
}
Limitations and Workarounds
API Constraints
Rate limits are undocumented but enforceable. Cache aggressively. The 500-post maximum requires pagination for large archives. No webhook support exists for real-time updates. Poll or rebuild on schedule.
Content Restrictions
Custom fields require hack solutions. Embed JSON in post content, use labels as key-value pairs, or maintain a separate mapping file. Blogger strips script tags and certain attributes from post HTML.
URL Structure Inflexibility
Permalink format locked to /YYYY/MM/slug.html. Configure reverse proxy or client-side routing to mask this structure. Redirect rules preserve SEO value during migration.
Security Considerations
Public blogs expose all published content. Draft posts remain inaccessible without authentication. For sensitive preview environments, implement a proxy with OAuth or use Blogger's private blog feature with service account access.
// OAuth2 for private blog access
const { google } = require('googleapis');
const auth = new google.auth.OAuth2(
process.env.CLIENT_ID,
process.env.CLIENT_SECRET,
process.env.REDIRECT_URI
);
const blogger = google.blogger({
version: 'v3',
auth
});
// Requires Blogger API v3 with OAuth
const posts = await blogger.posts.list({
blogId: BLOG_ID,
status: ['live', 'draft']
});
Verification Methods
Confirm API availability with direct curl request:
curl -s "https://www.blogger.com/feeds/BLOG_ID/posts/default?alt=json&max-results=1" | jq '.feed.entry[0].title'
Validate JSON structure against expected schema. Monitor response times. Check updated.$t freshness to detect stale cache.