It was 2 AM. I was staring at my VSCode editor, debugging why my latest blog post wasn’t rendering correctly. Again.
The problem? I had to edit JSX files, commit to Git, wait for deployment, find the typo, and repeat. For a simple blog post. Every. Single. Time.
That night, I decided: there had to be a better way.
Fast forward three weeks: I now publish content with one click. No code. No deployments. No 2 AM debugging sessions. This is how I built that system.
The Pain: Managing Content in a Static Site
what my workflow looked like before:
To publish one blog post:
- Open VSCode
- Create a new JSX file
- Import it in the Blog component
- Add metadata manually
- Write content (with syntax highlighting broken)
- Test locally
- Fix typos
- Commit to Git
- Push
- Wait 3-5 minutes for deployment
- Find another typo
- Repeat steps 7-10
Time investment: 15-20 minutes per post. Mental overhead: Exhausting.
And that’s just for blog posts. Research publications? Worse. Projects? Don’t get me started.
I spent more time managing code than creating content.
Why I Chose Decap CMS (And Why You Should Too)
I evaluated every headless CMS out there:
- Contentful: $300/month for my needs
- Sanity: Complex setup, overkill for a portfolio
- Strapi: Requires a server, database hosting costs
- WordPress: Too heavy, not developer-friendly
Then I found Decap CMS. It checked every box:
Git-based - Content lives in my repo as markdown Free forever - Zero hosting costs, no vendor lock-in Simple - One config file, works with any static site No backend - No servers, databases, or API calls
The kicker? It took 4 hours to set up. I’ve saved 10+ hours in the first month alone.
The Solution: How Decap CMS Works ?
Think of Decap CMS like WordPress, but instead of a database, it uses your Git repository. This is the brilliant part:
- You write content in a beautiful admin interface
- Click “Publish”
- CMS commits markdown files to your Git repo
- Your site rebuilds automatically
- Content goes live
No API calls. No database queries. Pure static files.
The Architecture (What I Built)
I organized my content like a filing cabinet:
content/
├── blog/ ← Blog articles (you're reading one!)
├── research/ ← Research papers & publications
├── patents/ ← Patent applications
└── projects/ ← Portfolio showcase
Each file is just markdown with some metadata at the top:
---
title: "My Article"
date: "2025-01-05"
tags: [React, CMS]
---
Article content here...
That’s it. No database schemas. No complex data models. Just files.
2. CMS Configuration
Created public/admin/config.yml:
backend:
name: git-gateway
branch: main
media_folder: "public/assets/images"
public_folder: "/assets/images"
collections:
- name: "blog"
label: "Blog Posts"
folder: "content/blog"
create: true
slug: "{{year}}-{{month}}-{{slug}}"
fields:
- {label: "Title", name: "title", widget: "string"}
- {label: "Publish Date", name: "date", widget: "datetime"}
- {label: "Author", name: "author", widget: "string"}
- {label: "Excerpt", name: "excerpt", widget: "text"}
- {label: "Category", name: "category", widget: "select"}
- {label: "Tags", name: "tags", widget: "list"}
- {label: "Published", name: "published", widget: "boolean"}
- {label: "Body", name: "body", widget: "markdown"}
3. Content Loader Utility
The magic happens in src/utils/cmsLoader.js:
import matter from 'gray-matter';
// Import all markdown files from content directories
const blogFiles = import.meta.glob('../content/blog/*.md', {
eager: true,
as: 'raw'
});
export const cmsBlog = Object.entries(blogFiles).map(([path, content]) => {
const { data, content: body } = matter(content);
const slug = path.split('/').pop().replace('.md', '');
return {
...data,
body,
slug
};
});
How it works:
- Vite’s
import.meta.globscans content folders gray-matterparses YAML frontmatter- Returns array of content objects
- Components consume via simple imports
4. Component Integration
Before (Hardcoded):
const blogPosts = [
{
title: "My First Post",
date: "2025-01-01",
content: "Lorem ipsum..."
}
];
After (CMS-Powered):
import { cmsBlog } from '../utils/cmsLoader';
const Blog = () => {
const posts = cmsBlog
.filter(post => post.published)
.sort((a, b) => new Date(b.date) - new Date(a.date));
return (
<div>
{posts.map(post => (
<ArticleCard key={post.slug} {...post} />
))}
</div>
);
};
How To Build This (Step-by-Step Guide)
Let me walk you through exactly how I built this. Follow along, and you’ll have a working CMS in under an hour.
Step 1: Install One Package
npm install gray-matter
That’s it. One dependency.
What is gray-matter? It’s a library that reads the metadata (frontmatter) from markdown files. Think of it as a translator between YAML and JavaScript.
Step 2: Set Up the Admin Panel (2 Minutes)
Create two files in your public/ folder:
public/admin/index.html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Content Manager</title>
<script src="https://unpkg.com/decap-cms@^3.0.0/dist/decap-cms.js"></script>
</head>
<body>
<!-- Decap CMS automatically renders here -->
</body>
</html>
That’s your entire admin interface. Seriously. Decap CMS loads from a CDN and handles the rest.
Visit yoursite.com/admin and you’ll see a beautiful CMS interface. (After we configure it in the next step.)
Step 3: Configure Collections
config.yml structure:
collections:
- name: "blog" # Internal identifier
label: "Blog Posts" # UI display name
folder: "content/blog" # Where files are stored
create: true # Allow creating new posts
slug: "{{slug}}" # Filename pattern
fields: [...] # Content fields
Step 4: Setup Authentication
GitHub Backend:
backend:
name: github
repo: your-username/your-portfolio
branch: main
OAuth Provider:
- Create GitHub OAuth App
- Add redirect URL:
https://yourdomain.com/admin/ - Configure Netlify/Vercel for authentication
Git Gateway (Easier):
backend:
name: git-gateway
branch: main
No OAuth setup needed - Netlify handles it.
Step 5: Create Content Loader
// src/utils/cmsLoader.js
import matter from 'gray-matter';
const loadContent = (files) => {
return Object.entries(files).map(([path, content]) => {
const { data, content: body } = matter(content);
return {
...data,
body,
slug: path.split('/').pop().replace('.md', '')
};
});
};
// Blog posts
const blogFiles = import.meta.glob('../content/blog/*.md', {
eager: true,
as: 'raw'
});
export const cmsBlog = loadContent(blogFiles);
// Research publications
const researchFiles = import.meta.glob('../content/research/*.md', {
eager: true,
as: 'raw'
});
export const cmsResearch = loadContent(researchFiles);
// Projects
const projectFiles = import.meta.glob('../content/projects/*.md', {
eager: true,
as: 'raw'
});
export const cmsProjects = loadContent(projectFiles);
Step 6: Update Components
Old approach:
// Hardcoded data in component
const projects = [
{ title: "Project 1", ... },
{ title: "Project 2", ... }
];
New approach:
import { cmsProjects } from '../utils/cmsLoader';
const Projects = () => {
const projects = cmsProjects;
// Render projects
};
Advanced Features I Implemented
1. Custom Widgets
For selecting technologies with autocomplete:
- label: "Technologies"
name: "technologies"
widget: "select"
multiple: true
options:
- "React"
- "Node.js"
- "Python"
- "TensorFlow"
2. Media Library
Automatic image upload and management:
media_folder: "public/assets/images"
public_folder: "/assets/images"
Upload images in CMS → Saved to Git → Available in components.
3. Preview Templates
Real-time preview as you write:
// public/admin/preview-templates/blog-preview.js
CMS.registerPreviewTemplate('blog', ({ entry, widgetFor }) => {
return (
<article>
<h1>{entry.getIn(['data', 'title'])}</h1>
<div>{widgetFor('body')}</div>
</article>
);
});
4. Editorial Workflow
Draft → Review → Published:
publish_mode: editorial_workflow
Three-column board:
- Drafts - Work in progress
- In Review - Ready for review
- Ready - Approved for publish
Content Types I Created
1. Blog Posts
Fields:
- Title (string)
- Date (datetime)
- Author (string)
- Excerpt (text)
- Category (select)
- Tags (list)
- Featured Image (image)
- Published (boolean)
- Body (markdown)
Example frontmatter:
---
title: "My Article Title"
date: "2025-01-05T10:00:00Z"
author: "Pavan Kumar"
excerpt: "Short description"
category: "Tutorial"
tags:
- React
- JavaScript
published: true
---
Article content here...
2. Research Publications
Fields:
- Title
- Authors (list)
- Venue (string)
- Date
- Abstract (text)
- Keywords (list)
- DOI (string)
- PDF Link (string)
- Status (select: Published/Under Review)
3. Projects
Fields:
- Title
- Description
- Technologies (list)
- GitHub URL
- Live Demo URL
- Screenshots (image list)
- Features (list)
- Status (select)
4. Patents
Fields:
- Title
- Inventors (list)
- Filing Date
- Patent Number
- Status
- Abstract
- Keywords
Benefits Realized
Before vs After:
| Task | Before | After |
|---|---|---|
| Add blog post | 15 min | 3 min |
| Update project | 10 min | 2 min |
| Add research | 20 min | 5 min |
| Fix typo | 5 min | 1 min |
| Total time saved | - | 70% faster |
Non-Technical Benefits:
- Write anywhere - CMS accessible from any device
- Rich editor - Markdown with live preview
- Media management - Drag-and-drop image uploads
- Version control - Every change is a Git commit
- Collaboration ready - Share admin access easily
Challenges & Solutions
Challenge 1: Hot Module Reload
Problem: Changes to markdown files didn’t trigger HMR.
Solution: Used Vite’s import.meta.glob with eager: true:
const files = import.meta.glob('../content/**/*.md', {
eager: true,
as: 'raw'
});
Now Vite watches markdown files and hot-reloads on changes.
Challenge 2: Image Paths
Problem: CMS saved images to /assets/images but build didn’t include them.
Solution: Configured media folder in public/:
media_folder: "public/assets/images"
public_folder: "/assets/images"
Images in public/ are copied to build output automatically.
Challenge 3: Authentication
Problem: GitHub OAuth setup was complex.
Solution: Used Netlify Identity + Git Gateway:
- Enable Netlify Identity
- Enable Git Gateway
- Invite users via email
- No OAuth app needed
Challenge 4: Preview Accuracy
Problem: CMS preview didn’t match live site styles.
Solution: Created custom preview templates that import actual components:
import BlogPost from '../../components/BlogPost';
CMS.registerPreviewTemplate('blog', ({ entry }) => {
return <BlogPost {...entry.toJS().data} />;
});
Best Practices I Learned
1. Slug Patterns
Use consistent, SEO-friendly patterns:
slug: "{{year}}-{{month}}-{{slug}}"
# Results in: 2025-01-my-article-title
2. Required Fields
Mark essential fields as required:
fields:
- {label: "Title", name: "title", widget: "string", required: true}
3. Default Values
Provide sensible defaults:
- label: "Published"
name: "published"
widget: "boolean"
default: false # New posts are drafts
4. Field Validation
Add validation patterns:
- label: "GitHub URL"
name: "github"
widget: "string"
pattern: ['https://github.com/.*', 'Must be a GitHub URL']
5. Collection Folders
Organize by content type:
content/
├── blog/ # Blog posts
├── research/ # Publications
├── projects/ # Portfolio projects
└── patents/ # Patent applications
Performance Impact
Bundle Size:
- gray-matter: +8KB gzipped
- CMS admin: Lazy loaded (doesn’t affect main app)
Build Time:
- Before: 2.3s
- After: 2.5s (+0.2s for markdown processing)
Runtime:
- Content loaded at build time
- Zero runtime overhead
- Same performance as hardcoded data
SEO Considerations
Markdown files work perfectly with SEO:
- Structured Data: Extract from frontmatter
const articleSchema = {
"@type": "BlogPosting",
"headline": post.title,
"datePublished": post.date,
"author": { "@type": "Person", "name": post.author }
};
- Meta Tags: Use frontmatter for SEO
<Helmet>
<title>{post.title}</title>
<meta name="description" content={post.excerpt} />
<meta name="keywords" content={post.tags.join(', ')} />
</Helmet>
- Sitemap: Generate from markdown files
const blogUrls = cmsBlog.map(post => ({
url: `/#blog/${post.slug}`,
lastmod: post.date,
priority: 0.7
}));
Deployment Workflow
Current Setup:
- Write content in CMS (https://yourdomain.com/admin)
- Save as draft
- Preview changes
- Publish → Creates Git commit
- GitHub Actions triggers build
- Deploy to Vercel/Netlify
Time from write to live: 2-3 minutes
Git History:
Every CMS save creates a clean commit:
feat: Create Blog "implementing-decap-cms-portfolio"
feat: Update Research "federated-learning-healthcare"
feat: Update Projects "vibetensor"
Future Enhancements
1. Localization
Support multiple languages:
collections:
- name: "blog"
i18n: true
locales: [en, es, fr]
2. Scheduled Publishing
Publish posts at specific times:
- label: "Publish Date"
name: "publishDate"
widget: "datetime"
3. Related Posts
Link related content:
- label: "Related Posts"
name: "related"
widget: "relation"
collection: "blog"
searchFields: ["title"]
4. Content Analytics
Track popular posts and optimize content strategy.
What I Learned (And What You Should Know)
Three weeks ago, I was editing JSX files at 2 AM. Today, I publish content from my phone while waiting for coffee.
This is what changed:
The Numbers
| Metric | Before Decap CMS | After Decap CMS |
|---|---|---|
| Time to publish blog post | 15-20 min | 3 min |
| Code changes per update | 5-10 files | 0 files |
| Deployment triggers | Every update | Automatic |
| Mental overhead | High | None |
| Hours saved monthly | - | 12+ hours |
The Real Benefits (Beyond Time)
- I actually write now. When publishing is friction-free, you create more.
- Non-developers can help. Collaborators don’t need Git knowledge.
- Content is portable. Markdown files work anywhere, forever.
- Version control is free. Every change is a Git commit with full history.
- Zero vendor lock-in. Your content lives in your repo, not someone’s database.
Should You Build This?
Yes, if you:
- Have a React/Vue/any static site portfolio
- Spend more time managing content than creating it
- Want to publish from anywhere (phone, tablet, any browser)
- Need a professional CMS without monthly fees
Skip it if you:
- Only update content once a year
- Love editing code (no judgment!)
- Already have a CMS you’re happy with
Your Next Steps (Do This Now)
Don’t let this article sit in your “read later” pile. Your action plan:
This week:
- Clone the basic setup (takes 10 minutes)
- Create one test blog post
- Publish it
Next week:
- Migrate your existing content
- Configure custom fields for your needs
- Set up authentication
Within a month: You’ll wonder how you ever managed content any other way.
Let’s Connect
Want to discuss this implementation or have questions?
Connect on LinkedIn Follow on GitHub
I’m always happy to help fellow developers implement similar solutions.
Resources That Helped Me
- Official Decap CMS Docs - Start here
- gray-matter GitHub - For parsing frontmatter
- Vite Glob Import Guide - Essential for dynamic imports
- Decap CMS GitHub - Official repository
One Last Thing
If you build this, I want to hear about it. Seriously.
- Have a question? Message me on LinkedIn
- Want to collaborate? Connect on GitHub
- Built something cool? Share it - I’d love to see your implementation!
The best technical content comes from sharing what we build. This is me sharing mine. Your turn.
Found this helpful? Share it with a developer friend who’s still editing JSX files to publish blog posts. They’ll thank you later.
Building something interesting with Decap CMS? I’d love to learn from your implementation. Drop a link in the comments below.