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:

  1. Open VSCode
  2. Create a new JSX file
  3. Import it in the Blog component
  4. Add metadata manually
  5. Write content (with syntax highlighting broken)
  6. Test locally
  7. Fix typos
  8. Commit to Git
  9. Push
  10. Wait 3-5 minutes for deployment
  11. Find another typo
  12. 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:

  1. You write content in a beautiful admin interface
  2. Click “Publish”
  3. CMS commits markdown files to your Git repo
  4. Your site rebuilds automatically
  5. 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:

  1. Vite’s import.meta.glob scans content folders
  2. gray-matter parses YAML frontmatter
  3. Returns array of content objects
  4. 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:

  1. Create GitHub OAuth App
  2. Add redirect URL: https://yourdomain.com/admin/
  3. 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:

TaskBeforeAfter
Add blog post15 min3 min
Update project10 min2 min
Add research20 min5 min
Fix typo5 min1 min
Total time saved-70% faster

Non-Technical Benefits:

  1. Write anywhere - CMS accessible from any device
  2. Rich editor - Markdown with live preview
  3. Media management - Drag-and-drop image uploads
  4. Version control - Every change is a Git commit
  5. 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:

  1. Enable Netlify Identity
  2. Enable Git Gateway
  3. Invite users via email
  4. 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:

  1. Structured Data: Extract from frontmatter
const articleSchema = {
  "@type": "BlogPosting",
  "headline": post.title,
  "datePublished": post.date,
  "author": { "@type": "Person", "name": post.author }
};
  1. 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>
  1. Sitemap: Generate from markdown files
const blogUrls = cmsBlog.map(post => ({
  url: `/#blog/${post.slug}`,
  lastmod: post.date,
  priority: 0.7
}));

Deployment Workflow

Current Setup:

  1. Write content in CMS (https://yourdomain.com/admin)
  2. Save as draft
  3. Preview changes
  4. Publish → Creates Git commit
  5. GitHub Actions triggers build
  6. 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"

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

MetricBefore Decap CMSAfter Decap CMS
Time to publish blog post15-20 min3 min
Code changes per update5-10 files0 files
Deployment triggersEvery updateAutomatic
Mental overheadHighNone
Hours saved monthly-12+ hours

The Real Benefits (Beyond Time)

  1. I actually write now. When publishing is friction-free, you create more.
  2. Non-developers can help. Collaborators don’t need Git knowledge.
  3. Content is portable. Markdown files work anywhere, forever.
  4. Version control is free. Every change is a Git commit with full history.
  5. 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:

  1. Clone the basic setup (takes 10 minutes)
  2. Create one test blog post
  3. Publish it

Next week:

  1. Migrate your existing content
  2. Configure custom fields for your needs
  3. 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

One Last Thing

If you build this, I want to hear about it. Seriously.

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.