How to Create a Next.js Blog - Part 4: Integrating with ElmapiCMS

Migrate your Next.js blog from local MDX files to ElmapiCMS headless CMS. Learn how to use the API, manage content through an admin panel, and set up webhooks for automatic rebuilds. Part 4 of our comprehensive tutorial series.

R
Raşit Apalak
17 min read

Migrating from MDX Files to a Headless CMS

In Parts 1-3, we built a blog using local MDX files. Now we'll migrate to ElmapiCMS, a headless CMS that allows you to manage content without touching code. This enables non-developers to create and edit posts, and provides a scalable content management solution.


Table of Contents


Why Migrate to a Headless CMS?

Benefits of Using ElmapiCMS

Content Management Without Code Changes

  • Non-developers can create and edit posts
  • No need to commit MDX files to git
  • Content updates don't require deployments

Multi-User Collaboration

  • Multiple authors can work simultaneously
  • Role-based permissions
  • Content approval workflows

API-First Approach

  • Content available via REST API
  • Use the same content across multiple platforms
  • Easy integration with other services

Scalability

  • Handle large amounts of content
  • Built-in pagination and filtering
  • Optimized API responses

Media Management

  • Centralized asset library
  • Image optimization
  • Cloud storage support (S3, DigitalOcean Spaces, etc.)

Prerequisites

Before starting, make sure you have:

  • Completed Parts 1-3 of this tutorial
  • An ElmapiCMS instance running (local or remote)
  • Basic understanding of REST APIs
  • Your existing Next.js blog project

If you don't have ElmapiCMS installed yet, check out our installation guide.


Step 1: Setting Up ElmapiCMS

Installation Options

ElmapiCMS can be installed in several ways:

Option 1: Docker (Recommended for Development)

cd ~/projects/Elmapi3
composer install
cp .env.example .env
php artisan key:generate
php artisan sail:install
./vendor/bin/sail up
./vendor/bin/sail artisan migrate --seed

Option 2: Laravel Sail

If you're using Laravel Sail, follow the Docker guide under GitHub installation, depending on how you bought ElmapiCMS.

Option 3: VPS/Production

For production deployment, see the deployment documentation.

Creating Your First Project

  1. Access your ElmapiCMS admin panel (usually http://localhost:8000 for local development)
  2. Login with default credentials (check your installation docs)
  3. Click "Create Project"
  4. Name it "My Blog" and click "Create Project"

You'll be taken to your project dashboard where you can start creating collections.


Step 2: Creating the Blog Collection in ElmapiCMS

Using the Blog Post Template

ElmapiCMS includes a "Blog Post" template that's perfect for our use case:

  1. In your ElmapiCMS project, click "+ Add New" in the Collections sidebar
  2. Name it "Posts" (this will be the collection identifier)
  3. Choose "Blog Post" as the template
  4. Click "Create Collection"

This creates a collection with basic fields: title, slug, and content.

Adding Custom Fields

We need to add fields that match our MDX frontmatter structure. Click the settings icon next to your collection and add these fields:

  1. date (Date field) - Publication date
  2. description (Text field) - Post description/excerpt
  3. image (Media field) - Featured image
  4. image_dark (Media field) - Dark mode variant
  5. category (Text field) - Post category

Field Configuration Tips:

  • Set slug to auto-generate from title
  • Make date required with a default value
  • Set category as optional
  • Configure image and image_dark to accept images only

Understanding Collection Structure

Your collection structure should look like this:

Posts Collection
├── title (Text, Required)
├── slug (Text, Auto-generated)
├── content (Rich Text/Markdown)
├── date (Date, Required)
├── description (Text)
├── image (Media)
├── image_dark (Media)
└── category (Text)

Step 3: Installing the ElmapiCMS JavaScript SDK

The ElmapiCMS JavaScript SDK provides a simple interface to interact with the API.

Installing the SDK

In your Next.js project, install the SDK:

npm install @elmapicms/js-sdk

Understanding SDK Methods

The SDK provides these main methods:

  • getEntries(collection, params) - Get multiple entries with filtering, pagination, sorting
  • getEntry(collection, uuid, params) - Get a single entry by UUID
  • createEntry(collection, data) - Create a new entry
  • updateEntry(collection, uuid, data) - Update an entry
  • deleteEntry(collection, uuid) - Delete an entry

For our blog, we'll primarily use getEntries() and getEntry().


Step 4: Configuring Environment Variables

Create or update .env.local in your Next.js project:

ELMAPI_API_URL=http://localhost:8000/api
ELMAPI_API_KEY=your-api-key-here
ELMAPI_PROJECT_ID=your-project-uuid-here
ELMAPI_IMAGE_HOST=localhost:8000
NEXT_PUBLIC_SITE_URL=http://localhost:3000

Finding Your Project ID

  1. Go to your ElmapiCMS project
  2. Navigate to SettingsAPI Access
  3. Copy the Project ID (UUID format)
  4. Paste it into .env.local

Creating an API Key

  1. In the same API Access page
  2. Click "Create Token"
  3. Give it a name like "Blog Read-Only"
  4. Select abilities: Read only (for security)
  5. Copy the generated token
  6. Paste it into .env.local

Tip: You can enable "Public GET Access" if you want to allow unauthenticated reads. In this case, you can leave ELMAPI_API_KEY empty.

Production Configuration

For production, update the URLs:

ELMAPI_API_URL=https://your-domain.com/api
ELMAPI_IMAGE_HOST=your-domain.com
NEXT_PUBLIC_SITE_URL=https://your-blog-domain.com

Step 5: Creating the API Client

Create src/lib/elmapicms.ts:

import { createClient } from '@elmapicms/js-sdk'

// Lazy initialization of SDK client
function getClient() {
  const apiUrl = process.env.ELMAPI_API_URL || 'http://localhost:8000/api'
  const apiToken = process.env.ELMAPI_API_KEY || ''
  const projectId = process.env.ELMAPI_PROJECT_ID || ''

  if (!projectId) {
    throw new Error('ELMAPI_PROJECT_ID environment variable is required')
  }

  return createClient(apiUrl, apiToken, projectId)
}

// Type definitions matching your ElmapiCMS collection structure
export interface ElmapiPost {
  uuid: string
  locale: string
  published_at: string | null
  fields: {
    title: string
    slug: string
    content: string
    date: string
    description?: string
    image?: string
    image_dark?: string
    category?: string
  }
}

export const client = getClient()

This creates a reusable client that you can import throughout your application.


Step 6: Migrating Posts Utility Functions

Now let's update src/lib/posts.ts to use the API instead of reading files.

Updating getSortedPostsData()

Replace the file-based implementation with API calls:

import { client, ElmapiPost } from './elmapicms'
import { Post, PostMatter } from './posts'

// Transform ElmapiCMS entry to our Post format
function transformEntry(entry: ElmapiPost): PostMatter {
  return {
    slug: entry.fields.slug,
    title: entry.fields.title,
    date: entry.fields.date,
    description: entry.fields.description || '',
    image: entry.fields.image || undefined,
    image_dark: entry.fields.image_dark || undefined,
    category: entry.fields.category || undefined,
  }
}

export async function getSortedPostsData(): Promise<PostMatter[]> {
  try {
    const entries = await client.getEntries('posts', {
      sort: 'date:DESC', // Sort by date descending
    }) as ElmapiPost[]

    const posts = entries.map(transformEntry)
    return posts
  } catch (error) {
    console.error('Error fetching posts:', error)
    return []
  }
}

Updating getPostData()

export async function getPostData(slug: string): Promise<Post> {
  try {
    // First, find the post by slug
    const entries = await client.getEntries('posts', {
      where: {
        slug: slug,
      },
    }) as ElmapiPost[]

    if (entries.length === 0) {
      throw new Error('Post not found')
    }

    const entry = entries[0]
    return {
      slug: entry.fields.slug,
      title: entry.fields.title,
      date: entry.fields.date,
      description: entry.fields.description || '',
      content: entry.fields.content,
      image: entry.fields.image || undefined,
      image_dark: entry.fields.image_dark || undefined,
      category: entry.fields.category || undefined,
    }
  } catch (error) {
    console.error('Error fetching post:', error)
    throw new Error('Post not found')
  }
}

Updating getAllPostSlugs()

export async function getAllPostSlugs() {
  try {
    const entries = await client.getEntries('posts') as ElmapiPost[]
    return entries.map((entry) => ({
      slug: entry.fields.slug,
    }))
  } catch (error) {
    console.error('Error fetching post slugs:', error)
    return []
  }
}

Updating Category Functions

export async function getAllCategories(): Promise<string[]> {
  try {
    const entries = await client.getEntries('posts') as ElmapiPost[]
    const categories = entries
      .map((entry) => entry.fields.category)
      .filter((category): category is string => Boolean(category))
    return Array.from(new Set(categories)).sort()
  } catch (error) {
    console.error('Error fetching categories:', error)
    return []
  }
}

export async function getPostsByCategory(category: string): Promise<PostMatter[]> {
  try {
    const entries = await client.getEntries('posts', {
      where: {
        category: category,
      },
      sort: 'date:DESC',
    }) as ElmapiPost[]
    return entries.map(transformEntry)
  } catch (error) {
    console.error('Error fetching posts by category:', error)
    return []
  }
}

export async function getCategoryBySlug(slug: string): Promise<string | null> {
  const categories = await getAllCategories()
  const matchedCategory = categories.find(
    (category) => slugify(category) === slug.toLowerCase()
  )
  return matchedCategory || null
}

Updating getRelatedPosts()

export async function getRelatedPosts(currentSlug: string, limit: number = 3): Promise<PostMatter[]> {
  try {
    // Get current post
    const currentPost = await getPostData(currentSlug)
    
    // Get all posts
    const allPosts = await getSortedPostsData()
    const otherPosts = allPosts.filter((post) => post.slug !== currentSlug)

    if (otherPosts.length === 0) {
      return []
    }

    // Filter by category
    const sameCategoryPosts = otherPosts.filter(
      (post) => post.category && post.category === currentPost.category
    )
    const differentCategoryPosts = otherPosts.filter(
      (post) => !post.category || post.category !== currentPost.category
    )

    // Shuffle and mix (same logic as before)
    const shuffle = <T,>(array: T[]): T[] => {
      const shuffled = [...array]
      for (let i = shuffled.length - 1; i > 0; i--) {
        const j = Math.floor(Math.random() * (i + 1))
        [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]
      }
      return shuffled
    }

    const shuffledSameCategory = shuffle(sameCategoryPosts)
    const shuffledDifferentCategory = shuffle(differentCategoryPosts)

    const relatedPosts: PostMatter[] = []
    const sameCategoryCount = Math.min(2, shuffledSameCategory.length, limit)
    relatedPosts.push(...shuffledSameCategory.slice(0, sameCategoryCount))

    const remaining = limit - relatedPosts.length
    if (remaining > 0 && shuffledDifferentCategory.length > 0) {
      relatedPosts.push(...shuffledDifferentCategory.slice(0, remaining))
    }

    return relatedPosts.slice(0, limit)
  } catch (error) {
    console.error('Error fetching related posts:', error)
    return []
  }
}

Step 7: Handling MDX Content from API

ElmapiCMS stores content as Markdown or Rich Text. We can render it the same way we did with MDX files.

Storing MDX in ElmapiCMS

When creating posts in ElmapiCMS:

  1. Use the content field (configured as Markdown or Rich Text)
  2. Write your content in Markdown format
  3. Include code blocks, headings, lists, etc., just like in MDX files

Rendering MDX from API

The getPostData() function already returns the content. Your existing MDX rendering code will work:

<MDXRemote 
  source={post.content}
  components={{
    pre: CodeBlock,
  }}
/>

This works because:

  • ElmapiCMS stores content as Markdown
  • next-mdx-remote can render Markdown
  • Your custom components (like CodeBlock) still work

Preserving Code Blocks and Formatting

Make sure your ElmapiCMS content field is configured as Markdown (not Rich Text) to preserve:

  • Code blocks with syntax highlighting
  • Headings with IDs (for table of contents)
  • Lists, links, and other Markdown features

Step 8: Image Handling

ElmapiCMS provides an Asset Library for managing images.

Fetching Images from Asset Library

When you upload images in ElmapiCMS, they're stored in the Asset Library. The API returns image URLs like:

http://localhost:8000/storage/assets/image-name.webp

Using Image URLs from API

Update your image handling to use the full URL:

// In getPostData() or transformEntry()
image: entry.fields.image 
  ? `${process.env.ELMAPI_IMAGE_HOST || 'http://localhost:8000'}${entry.fields.image}`
  : undefined

Or configure ElmapiCMS to return full URLs in the API response.

Handling Dark Mode Images

If you have separate images for dark mode:

image_dark: entry.fields.image_dark
  ? `${process.env.ELMAPI_IMAGE_HOST || 'http://localhost:8000'}${entry.fields.image_dark}`
  : undefined

Image Optimization

Your existing OptimizedImage component will work with ElmapiCMS image URLs. Just make sure to:

  1. Configure next.config.ts to allow images from your ElmapiCMS domain:
images: {
  remotePatterns: [
    {
      protocol: 'http',
      hostname: 'localhost',
      port: '8000',
      pathname: '/storage/**',
    },
    {
      protocol: 'https',
      hostname: 'your-elmapicms-domain.com',
      pathname: '/storage/**',
    },
  ],
}

Step 9: API Authentication

Understanding API Tokens

ElmapiCMS uses API tokens for authentication. Each token has abilities that define what it can do:

  • Read - Can fetch entries
  • Create - Can create new entries
  • Update - Can update existing entries
  • Delete - Can delete entries

Creating Read-Only Tokens

For a blog, you typically only need Read ability:

  1. Go to SettingsAPI Access
  2. Click "Create Token"
  3. Name it "Blog Read-Only"
  4. Select only Read ability
  5. Copy the token

This token can only read content, making it safe to use in client-side code (if needed).

Public API vs Authenticated API

Public API (Recommended for Blogs):

  1. In SettingsAPI Access
  2. Enable "Public GET Access"
  3. No token needed for reading
  4. Still secure (read-only)

Authenticated API:

  • Requires a token for all requests
  • More secure
  • Better for private content

Security Best Practices

  • Use read-only tokens for public blogs
  • Never commit API keys to git
  • Use environment variables
  • Rotate tokens periodically
  • Use different tokens for development and production

Step 10: Updating Pages for API Integration

Your existing pages should work with minimal changes since we've updated the utility functions.

Blog Listing Page

src/app/blog/page.tsx should work as-is:

import { getSortedPostsData } from '@/lib/posts'
import { BlogList } from '@/components/blog-list'

export default async function Blog() {
  const posts = await getSortedPostsData() // Now fetches from API
  return <BlogList posts={posts} />
}

Individual Post Page

src/app/blog/[slug]/page.tsx should also work:

import { getPostData, getAllPostSlugs } from '@/lib/posts'

export async function generateStaticParams() {
  const posts = await getAllPostSlugs() // Now fetches from API
  return posts.map((post) => ({
    slug: post.slug,
  }))
}

export default async function PostPage({ params }: { params: PageParams }) {
  const post = await getPostData(slug) // Now fetches from API
  // ... rest of component
}

Error Handling

Add better error handling:

export default async function PostPage({ params }: { params: PageParams }) {
  const { slug } = await params
  try {
    const post = await getPostData(slug)
    // ... render post
  } catch (error) {
    console.error('Error loading post:', error)
    notFound()
  }
}

Loading States

For better UX, you can add loading states (though with static generation, this is less critical):

export default async function Blog() {
  const posts = await getSortedPostsData()
  
  if (posts.length === 0) {
    return <div>No posts found. Check your ElmapiCMS connection.</div>
  }
  
  return <BlogList posts={posts} />
}

Step 11: Advanced API Features

Pagination

ElmapiCMS supports pagination:

const entries = await client.getEntries('posts', {
  paginate: 10, // Posts per page
  page: 1, // Current page
})

Filtering

Filter posts by category or other fields:

const entries = await client.getEntries('posts', {
  where: {
    category: 'Tutorial',
  },
})

Search Functionality

You can implement server-side search:

const entries = await client.getEntries('posts', {
  where: {
    title: {
      like: `%${searchQuery}%`,
    },
  },
})

Sorting

Sort posts by any field:

const entries = await client.getEntries('posts', {
  sort: 'date:DESC', // or 'date:ASC'
})

Step 12: Webhooks for Rebuilds (Optional)

When content changes in ElmapiCMS, you can trigger a Next.js rebuild using webhooks.

Setting Up Webhooks in ElmapiCMS

  1. Go to SettingsWebhooks
  2. Click "Create Webhook"
  3. Configure:
    • URL: Your rebuild endpoint (see below)
    • Events: Select "Entry Created", "Entry Updated", "Entry Deleted"
    • Secret: Generate a secret for verification

Vercel Deployment Hook

If using Vercel:

  1. Go to your Vercel project settings
  2. Navigate to DeploymentsDeploy Hooks
  3. Create a new hook
  4. Copy the URL
  5. Use it as your webhook URL in ElmapiCMS

Netlify Build Hook

If using Netlify:

  1. Go to Site settingsBuild & deployContinuous Deployment
  2. Click "Add build hook"
  3. Copy the URL
  4. Use it in ElmapiCMS

GitHub Actions

For GitHub Actions, create .github/workflows/rebuild.yml:

name: Rebuild Site

on:
  repository_dispatch:
    types: [rebuild]

jobs:
  rebuild:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
      - run: npm install
      - run: npm run build
      # Add deployment steps here

Then create a webhook endpoint that triggers this workflow.

Verifying Webhook Signatures

Always verify webhook signatures for security:

import crypto from 'crypto'

export async function POST(request: Request) {
  const signature = request.headers.get('x-elmapi-signature')
  const body = await request.text()
  const secret = process.env.ELMAPI_WEBHOOK_SECRET

  const expectedSignature = crypto
    .createHmac('sha256', secret)
    .update(body)
    .digest('hex')

  if (signature !== expectedSignature) {
    return new Response('Invalid signature', { status: 401 })
  }

  // Trigger rebuild
  // ...
}

Step 13: Migration Strategy

Exporting Existing MDX Posts

Create a migration script scripts/export-mdx-to-json.ts:

import { getAllPostSlugs, getPostData } from '../src/lib/posts'
import fs from 'fs/promises'
import path from 'path'

async function exportPosts() {
  const slugs = await getAllPostSlugs()
  const posts = []

  for (const { slug } of slugs) {
    const post = await getPostData(slug)
    posts.push({
      title: post.title,
      slug: post.slug,
      content: post.content,
      date: post.date,
      description: post.description,
      category: post.category,
    })
  }

  await fs.writeFile(
    path.join(process.cwd(), 'posts-export.json'),
    JSON.stringify(posts, null, 2)
  )

  console.log(`Exported ${posts.length} posts to posts-export.json`)
}

exportPosts()

Importing to ElmapiCMS

  1. Use the ElmapiCMS Import feature
  2. Or manually create posts through the admin panel
  3. Or use the API to create posts programmatically

Testing the Migration

  1. Start with a few test posts
  2. Verify all features work:
    • Blog listing
    • Individual posts
    • Search
    • Categories
    • Related posts
  3. Gradually migrate remaining posts

Rollback Plan

Keep your MDX files in a backup branch:

git checkout -b backup-mdx-files
git add content/blog/
git commit -m "Backup MDX files before migration"
git checkout main

If needed, you can revert to the MDX-based implementation.


Step 14: Performance Considerations

Caching API Responses

Next.js automatically caches API responses during build. For dynamic updates, consider:

Option 1: Incremental Static Regeneration (ISR)

export const revalidate = 3600 // Revalidate every hour

Option 2: On-Demand Revalidation

Create an API route:

// app/api/revalidate/route.ts
export async function POST(request: Request) {
  const { secret, slug } = await request.json()

  if (secret !== process.env.REVALIDATION_SECRET) {
    return new Response('Invalid secret', { status: 401 })
  }

  try {
    await revalidatePath(`/blog/${slug}`)
    return Response.json({ revalidated: true })
  } catch (err) {
    return Response.json({ revalidated: false }, { status: 500 })
  }
}

Static Generation Benefits

generateStaticParams() still works with API data:

export async function generateStaticParams() {
  const posts = await getAllPostSlugs() // Fetches from API at build time
  return posts.map((post) => ({
    slug: post.slug,
  }))
}

This pre-generates all pages at build time for optimal performance.

API Rate Limiting

Be aware of API rate limits:

  • Don't fetch all posts on every request
  • Cache responses when possible
  • Use pagination for large datasets

Troubleshooting

Issue: API requests failing

Solution:

  • Check your ELMAPI_API_URL is correct
  • Verify your API key has the right abilities
  • Check if Public GET Access is enabled
  • Ensure your ElmapiCMS instance is running

Issue: Images not loading

Solution:

  • Check ELMAPI_IMAGE_HOST configuration
  • Verify image URLs in API responses
  • Update next.config.ts remote patterns
  • Ensure images are uploaded to Asset Library

Issue: Content not updating

Solution:

  • Clear Next.js cache: .next folder
  • Rebuild the site
  • Check webhook configuration
  • Verify ISR revalidation settings

Issue: Build errors

Solution:

  • Ensure all environment variables are set
  • Check API connectivity during build
  • Add error handling for API failures
  • Consider fallback to empty arrays

Conclusion

Congratulations! You've successfully migrated your Next.js blog from local MDX files to ElmapiCMS headless CMS. You now have:

✅ Content management through a user-friendly admin panel
✅ API-based content delivery
✅ Scalable architecture
✅ Multi-user collaboration support
✅ Webhook integration for automatic rebuilds

Key Takeaways:

  • ElmapiCMS provides a powerful API for content management
  • Migration from MDX to API is straightforward
  • Static generation still works with API data
  • Webhooks enable automatic site updates
  • The same blog features work with API data

Benefits Achieved:

  • Non-developers can now create and edit posts
  • Content updates don't require code deployments
  • Scalability for large amounts of content
  • Flexibility to use content across multiple platforms

Your blog is now production-ready with a professional content management system!

Next Steps:

  • Add more content through ElmapiCMS
  • Configure webhooks for automatic rebuilds
  • Set up production environment variables
  • Optimize images in the Asset Library
  • Explore advanced ElmapiCMS features

Happy blogging! 🎉

Share this post:

Related posts