Blogger as a Headless CMS
Blogger serves JSON feeds natively through its /feeds/posts/default endpoint. Any client can query this structured data without server-side rendering. The platform becomes a content backend when you decouple its storage from its frontend presentation.
Feed Architecture and Data Structure
Blogger exposes three feed formats. The default Atom feed contains full post content in HTML. The alt=json parameter returns JSON. The alt=json-in-script variant enables cross-origin requests without CORS configuration.
Each feed entry contains id, published, updated, title, content, author, link, and category nodes. The content node holds the full HTML body. Labels map to the category array for taxonomy filtering.
Image assets within posts reference Blogger's CDN directly. These URLs remain stable unless the image is reuploaded.
Feed Endpoint Construction
Construct requests using your blog's base URL followed by the feed path. Replace BLOGSPOT_DOMAIN with your actual subdomain.
// Atom XML (default)
https://BLOGSPOT_DOMAIN.blogspot.com/feeds/posts/default
// JSON
https://BLOGSPOT_DOMAIN.blogspot.com/feeds/posts/default?alt=json
// JSON with callback for JSONP
https://BLOGSPOT_DOMAIN.blogspot.com/feeds/posts/default?alt=json-in-script&callback=handleData
// Single post by path
https://BLOGSPOT_DOMAIN.blogspot.com/feeds/posts/default/BLOG_POST_PATH?alt=json
// Filtered by label
https://BLOGSPOT_DOMAIN.blogspot.com/feeds/posts/default/-/LABEL_NAME?alt=json
// Paginated results
https://BLOGSPOT_DOMAIN.blogspot.com/feeds/posts/default?alt=json&max-results=10&start-index=11
The max-results parameter accepts values up to 500. The start-index parameter enables pagination. The updated-min and updated-max parameters filter by date range in ISO 8601 format.
Client-Side Fetch Implementation
Modern browsers fetch JSON directly. Legacy implementations require JSONP due to missing CORS headers on Blogger feeds.
// Native fetch with JSON
async function fetchPosts(blogDomain, options = {}) {
const params = new URLSearchParams({
alt: 'json',
'max-results': options.limit || 10,
...options.startIndex && { 'start-index': options.startIndex },
...options.label && { category: options.label }
});
const response = await fetch(
`https://${blogDomain}.blogspot.com/feeds/posts/default?${params}`
);
if (!response.ok) {
throw new Error(`Feed error: ${response.status}`);
}
const data = await response.json();
return normalizeFeed(data);
}
// Normalize Blogger's nested structure
function normalizeFeed(feedData) {
const entries = feedData.feed.entry || [];
return entries.map(entry => ({
id: entry.id.$t,
title: entry.title.$t,
published: entry.published.$t,
updated: entry.updated.$t,
content: entry.content?.$t || '',
summary: entry.summary?.$t || '',
labels: entry.category?.map(c => c.term) || [],
author: {
name: entry.author?.[0]?.name?.$t,
uri: entry.author?.[0]?.uri?.$t
},
links: entry.link?.reduce((acc, l) => {
if (l.rel) acc[l.rel] = l.href;
return acc;
}, {}),
images: extractImages(entry.content?.$t || '')
}));
}
function extractImages(htmlContent) {
const parser = new DOMParser();
const doc = parser.parseFromString(htmlContent, 'text/html');
return [...doc.querySelectorAll('img')].map(img => ({
src: img.src,
alt: img.alt,
width: img.width,
height: img.height
}));
}
Static Site Integration Patterns
Build-time fetching preloads content into static generators. Runtime fetching defers loading to the client. Hybrid approaches cache feeds and revalidate on intervals.
Build-Time with Node.js
// scripts/fetch-content.js
const fs = require('fs');
const path = require('path');
async function fetchAllPosts(domain) {
const allPosts = [];
let startIndex = 1;
const maxResults = 500;
while (true) {
const params = new URLSearchParams({
alt: 'json',
'max-results': maxResults,
'start-index': startIndex
});
const res = await fetch(`https://${domain}.blogspot.com/feeds/posts/default?${params}`);
const data = await res.json();
const entries = data.feed.entry || [];
allPosts.push(...entries);
if (entries.length < maxResults) break;
startIndex += maxResults;
// Rate limit protection
await new Promise(r => setTimeout(r, 500));
}
return allPosts;
}
// Execute and write to build directory
fetchAllPosts('yourblog')
.then(posts => {
const output = path.resolve('./src/data/posts.json');
fs.mkdirSync(path.dirname(output), { recursive: true });
fs.writeFileSync(output, JSON.stringify(posts, null, 2));
console.log(`Cached ${posts.length} posts`);
})
.catch(console.error);
Vite Integration
// vite.config.js
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [{
name: 'blogger-feed',
async buildStart() {
const posts = await fetchAllPosts('yourblog');
this.emitFile({
type: 'asset',
fileName: 'data/posts.json',
source: JSON.stringify(posts)
});
}
}]
});
React Component for Live Fetching
// components/BloggerFeed.jsx
import { useState, useEffect } from 'react';
const CACHE_KEY = 'blogger_posts_cache';
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
export function useBloggerPosts(blogDomain, options = {}) {
const [posts, setPosts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const cacheKey = `${CACHE_KEY}_${blogDomain}_${options.label || 'all'}`;
const cached = localStorage.getItem(cacheKey);
if (cached) {
const { data, timestamp } = JSON.parse(cached);
if (Date.now() - timestamp < CACHE_TTL) {
setPosts(data);
setLoading(false);
return;
}
}
fetchPosts(blogDomain, options)
.then(data => {
setPosts(data);
localStorage.setItem(cacheKey, JSON.stringify({
data,
timestamp: Date.now()
}));
})
.catch(setError)
.finally(() => setLoading(false));
}, [blogDomain, options.label]);
return { posts, loading, error };
}
// Usage in component
export function PostList({ blogDomain }) {
const { posts, loading, error } = useBloggerPosts(blogDomain, {
limit: 10,
label: 'featured'
});
if (loading) return <div className="skeleton-grid">...</div>;
if (error) return <div className="error">Failed to load content</div>;
return (
<div className="post-grid">
{posts.map(post => (
<article key={post.id} className="glass-card">
<h2>{post.title}</h2>
<time dateTime={post.published}>
{new Date(post.published).toLocaleDateString()}
</time>
<div
className="content"
dangerouslySetInnerHTML={{ __html: sanitizeContent(post.content) }}
/>
</article>
))}
</div>
);
}
Content Sanitization Requirements
Blogger feed content contains raw HTML. Client-side rendering requires sanitization to prevent XSS through malicious post content or compromised accounts.
// utils/sanitize.js
import DOMPurify from 'dompurify';
const ALLOWED_TAGS = [
'p', 'br', 'strong', 'em', 'u', 'h1', 'h2', 'h3',
'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'a', 'img',
'blockquote', 'code', 'pre', 'table', 'thead',
'tbody', 'tr', 'td', 'th', 'div', 'span'
];
const ALLOWED_ATTR = [
'href', 'src', 'alt', 'title', 'class', 'id',
'width', 'height', 'target', 'rel'
];
export function sanitizeContent(dirtyHtml) {
return DOMPurify.sanitize(dirtyHtml, {
ALLOWED_TAGS,
ALLOWED_ATTR,
FORBID_ATTR: ['style'], // Strip inline styles
ADD_ATTR: ['target'], // Ensure external links open safely
hook: {
afterSanitizeAttributes: (node) => {
// Force external links to open in new tab with security attributes
if (node.tagName === 'A' && node.hasAttribute('href')) {
const href = node.getAttribute('href');
if (href.startsWith('http') && !href.includes(window.location.hostname)) {
node.setAttribute('target', '_blank');
node.setAttribute('rel', 'noopener noreferrer');
}
}
// Lazy load images
if (node.tagName === 'IMG') {
node.setAttribute('loading', 'lazy');
}
}
}
});
}
Image Optimization Pipeline
Blogger serves images through Google's CDN with resize parameters. Append query parameters to request specific dimensions without server processing.
// Transform Blogger image URLs for responsive delivery
function optimizeImageUrl(originalUrl, options = {}) {
const url = new URL(originalUrl);
// Only modify Google User Content URLs
if (!url.hostname.includes('googleusercontent.com')) {
return originalUrl;
}
const params = new URLSearchParams(url.search);
// Size constraints
if (options.width) params.set('w', options.width);
if (options.height) params.set('h', options.height);
// Quality: 10-100
if (options.quality) params.set('q', options.quality);
// Format: auto webp conversion
if (options.format) params.set('fm', options.format);
// Crop mode: smart, center, top, etc.
if (options.fit) params.set('fit', options.fit);
// Common presets
const presets = {
thumbnail: { width: 320, height: 240, quality: 80, fit: 'crop' },
card: { width: 800, quality: 85, fit: 'max' },
hero: { width: 1600, quality: 90, fit: 'max' },
full: { quality: 95, fit: 'max' }
};
const preset = presets[options.preset] || {};
const finalParams = new URLSearchParams({
...preset,
...Object.fromEntries(params)
});
return `${url.origin}${url.pathname}?${finalParams.toString()}`;
}
// Usage
const thumbnail = optimizeImageUrl(imgSrc, { preset: 'thumbnail' });
const responsive = optimizeImageUrl(imgSrc, {
width: 800,
quality: 85,
format: 'webp'
});
Search Implementation
Blogger's native search returns HTML pages, not structured data. Implement client-side search by indexing fetched posts.
// utils/search.js
export function createSearchIndex(posts) {
return posts.map(post => ({
...post,
// Flatten searchable text
searchText: [
post.title,
post.content.replace(/<[^>]+>/g, ' '),
post.labels.join(' ')
].join(' ').toLowerCase()
}));
}
export function searchPosts(index, query) {
const terms = query.toLowerCase().split(/\s+/).filter(t => t.length > 2);
return index
.map(post => {
const matches = terms.filter(term =>
post.searchText.includes(term)
).length;
return { post, score: matches / terms.length };
})
.filter(r => r.score > 0)
.sort((a, b) => b.score - a.score)
.map(r => r.post);
}
// Web Worker for large indexes
// search-worker.js
self.addEventListener('message', ({ data: { posts, query } }) => {
const results = searchPosts(posts, query);
self.postMessage({ results });
});
Common Failure Points and Resolution
CORS Errors on Direct Fetch
Blogger feeds omit CORS headers for alt=json on some regions. Solutions include proxying through a worker or using JSONP.
// JSONP fallback for CORS-blocked environments
function jsonpFetch(url) {
return new Promise((resolve, reject) => {
const script = document.createElement('script');
const callbackName = `jsonp_${Date.now()}`;
window[callbackName] = (data) => {
delete window[callbackName];
document.head.removeChild(script);
resolve(data);
};
script.src = `${url}&callback=${callbackName}`;
script.onerror = reject;
document.head.appendChild(script);
setTimeout(reject, 10000); // Timeout
});
}
Feed Rate Limiting
Unauthenticated requests face IP-based rate limits. Symptoms include 503 errors or empty responses. Mitigate with caching, request batching, and exponential backoff.
async function fetchWithRetry(url, options = {}, retries = 3) {
const delay = Math.pow(2, 3 - retries) * 1000;
try {
const res = await fetch(url, options);
if (res.status === 503 && retries > 0) {
await new Promise(r => setTimeout(r, delay));
return fetchWithRetry(url, options, retries - 1);
}
return res;
} catch (err) {
if (retries > 0) {
await new Promise(r => setTimeout(r, delay));
return fetchWithRetry(url, options, retries - 1);
}
throw err;
}
}
Content Encoding Issues
Blogger returns feeds with XML-encoded entities within JSON strings. Decode before rendering.
function decodeEntities(str) {
const textarea = document.createElement('textarea');
textarea.innerHTML = str;
return textarea.value;
}
// Apply during normalization
function normalizeEntry(entry) {
return {
title: decodeEntities(entry.title.$t),
content: decodeEntities(entry.content.$t)
};
}
Verification Methods
Confirm feed availability by requesting the base endpoint with curl. Validate JSON structure against expected schema.
# Verify feed accessibility
curl -I "https://yourblog.blogspot.com/feeds/posts/default?alt=json"
# Expected: HTTP/2 200 with Content-Type: application/json; charset=UTF-8
# Full content test
curl -s "https://yourblog.blogspot.com/feeds/posts/default?alt=json&max-results=1" | jq '.feed.entry[0].title'
# Validate image optimization
curl -I "https://BLOGGER_IMAGE_URL=w800-h600-p-k-no-nu"
Monitor feed health through periodic HEAD requests. Alert when status deviates from 200 or response time exceeds thresholds.
Limitations and Constraints
Blogger imposes a 500 post maximum per feed request. Pagination handles larger archives but increases total fetch time. The platform offers no webhook for content changes. Polling intervals must balance freshness against rate limits.
Custom domains require feed URL adjustment to the custom domain rather than the blogspot subdomain. Mixed content rules block HTTP resources on HTTPS pages.
Comment data requires separate API access through the /feeds/comments/default endpoint. No native draft preview exists for headless consumers.
Architecture Comparison
| Pattern | Build-Time | Runtime | Hybrid |
|---|---|---|---|
| Freshness | On deploy only | Immediate | Configurable TTL |
| SEO | Pre-rendered HTML | Requires SSR/ISR | Best of both |
| Complexity | Low | Medium | High |
| Best For | Static sites, blogs | Apps, dashboards | Large publications |