Next.js + ElmapiCMS Starter Template: Complete Setup Guide

Set up a Next.js 16 project with ElmapiCMS in 15 minutes. App Router, TypeScript, and Tailwind CSS included. Copy-paste code examples.

R
Raşit Apalak
5 min read

From zero to deployed in 15 minutes

You want to build a Next.js site with a headless CMS. You don't want to spend hours configuring. This guide gets you from nothing to a working blog with ElmapiCMS as fast as possible.


Table of Contents


What We're Building

A blog with:

  • Blog listing page with pagination
  • Individual blog post pages
  • Automatic revalidation when content changes
  • TypeScript throughout
  • Tailwind CSS styling

Tech stack:

  • Next.js 16 (App Router)
  • ElmapiCMS (self-hosted)
  • TypeScript
  • Tailwind CSS

Prerequisites


Step 1: Set Up ElmapiCMS

If you haven't already, get ElmapiCMS running:

# Using Docker
cd ~/projects/elmapicms
composer install
cp .env.example .env
php artisan key:generate
php artisan sail:install
./vendor/bin/sail up -d
./vendor/bin/sail artisan migrate --seed

Create a Project

  1. Go to http://localhost:8000
  2. Log in ([email protected] / password)
  3. Click "Create Project"
  4. Name it "My Blog"

Create Posts Collection

  1. Click "+ Add New" in Collections
  2. Name: "Posts"
  3. Add these fields:
    • title (Text, required)
    • slug (Text, unique)
    • excerpt (Text)
    • content (Rich Text)
    • featuredImage (Media)
    • publishedAt (Date)

Add Sample Content

Create 2-3 test posts so we have something to display.

Get API Credentials

  1. Go to API Settings in your project
  2. Create a new API key (read-only)
  3. Note down:
    • API URL: http://localhost:8000/api
    • API Key: your-api-key
    • Project ID: your-project-id

Step 2: Create Next.js Project

npx create-next-app@16 my-blog --typescript --tailwind --eslint --app --src-dir
cd my-blog

Choose the defaults for all prompts.


Step 3: Install ElmapiCMS SDK

npm install @elmapicms/js-sdk

Step 4: Configure Environment

Create .env.local:

ELMAPI_URL=http://localhost:8000/api
ELMAPI_API_KEY=your-api-key
ELMAPI_PROJECT_ID=your-project-id
REVALIDATE_SECRET=your-secret-string-here

Step 5: Create CMS Client

Create src/lib/cms.ts:

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

export const cmsClient = createClient(
  process.env.ELMAPI_URL!,
  process.env.ELMAPI_API_KEY!,
  process.env.ELMAPI_PROJECT_ID!
);

// Types
export interface Post {
  uuid: string;
  fields: {
    title: string;
    slug: string;
    excerpt: string;
    content: string;
    featuredImage?: string;
    publishedAt: string;
  };
}

// Helper functions
export async function getAllPosts(): Promise<Post[]> {
  return cmsClient.getEntries('posts', {
    sort: '-publishedAt',
  });
}

export async function getPostBySlug(slug: string): Promise<Post | null> {
  const posts = await cmsClient.getEntries('posts', {
    filters: { slug },
  });
  return posts[0] || null;
}

export async function getPaginatedPosts(page: number = 1, perPage: number = 10) {
  return cmsClient.getEntries('posts', {
    sort: '-publishedAt',
    page,
    paginate: perPage,
  });
}

Step 6: Build Blog Pages

Blog Listing Page

Create src/app/blog/page.tsx:

import Link from 'next/link';
import { getAllPosts } from '@/lib/cms';

export const metadata = {
  title: 'Blog',
  description: 'Latest articles and tutorials',
};

export default async function BlogPage() {
  const posts = await getAllPosts();

  return (
    <main className="container mx-auto px-4 py-16 max-w-4xl">
      <h1 className="text-4xl font-bold mb-8">Blog</h1>
      
      <div className="space-y-8">
        {posts.map((post) => (
          <article key={post.uuid} className="border-b pb-8">
            <Link href={`/blog/${post.fields.slug}`}>
              <h2 className="text-2xl font-semibold hover:text-blue-600 transition-colors">
                {post.fields.title}
              </h2>
            </Link>
            <p className="text-gray-600 mt-2">{post.fields.excerpt}</p>
            <time className="text-sm text-gray-400 mt-2 block">
              {new Date(post.fields.publishedAt).toLocaleDateString()}
            </time>
          </article>
        ))}
      </div>

      {posts.length === 0 && (
        <p className="text-gray-500">No posts yet. Create some in ElmapiCMS!</p>
      )}
    </main>
  );
}

Individual Post Page

Create src/app/blog/[slug]/page.tsx:

import { notFound } from 'next/navigation';
import { getAllPosts, getPostBySlug } from '@/lib/cms';
import type { Metadata } from 'next';

interface Props {
  params: { slug: string };
}

export async function generateStaticParams() {
  const posts = await getAllPosts();
  return posts.map((post) => ({
    slug: post.fields.slug,
  }));
}

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const post = await getPostBySlug(params.slug);
  if (!post) return { title: 'Post Not Found' };

  return {
    title: post.fields.title,
    description: post.fields.excerpt,
  };
}

export default async function PostPage({ params }: Props) {
  const post = await getPostBySlug(params.slug);

  if (!post) {
    notFound();
  }

  return (
    <main className="container mx-auto px-4 py-16 max-w-3xl">
      <article>
        <h1 className="text-4xl font-bold mb-4">{post.fields.title}</h1>
        
        <time className="text-gray-500 block mb-8">
          {new Date(post.fields.publishedAt).toLocaleDateString()}
        </time>

        {post.fields.featuredImage && (
          <img 
            src={post.fields.featuredImage} 
            alt={post.fields.title}
            className="w-full rounded-lg mb-8"
          />
        )}

        <div 
          className="prose prose-lg max-w-none"
          dangerouslySetInnerHTML={{ __html: post.fields.content }} 
        />
      </article>
    </main>
  );
}

Step 7: Add Revalidation

Create src/app/api/revalidate/route.ts:

import { revalidatePath } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';

export async function POST(request: NextRequest) {
  const secret = request.headers.get('x-webhook-secret');

  if (secret !== process.env.REVALIDATE_SECRET) {
    return NextResponse.json({ error: 'Invalid secret' }, { status: 401 });
  }

  // Revalidate blog pages
  revalidatePath('/blog');
  revalidatePath('/blog/[slug]', 'page');

  return NextResponse.json({ revalidated: true, now: Date.now() });
}

Configure Webhook in ElmapiCMS

  1. Go to your project's Webhook settings
  2. Add a new webhook:
    • URL: https://your-site.com/api/revalidate
    • Method: POST
    • Header: x-webhook-secret: your-secret-string-here
    • Events: Entry Created, Entry Updated, Entry Deleted

Deploy

Vercel (Recommended)

npm install -g vercel
vercel

Add environment variables in Vercel dashboard.

Netlify

npm install -g netlify-cli
netlify deploy --prod

Add environment variables in Netlify dashboard.


Next Steps

You now have a working Next.js blog powered by ElmapiCMS. Here's what to explore next:

Add more features:

Improve the design:

  • Add a proper layout component
  • Implement dark mode
  • Add reading time estimates

Optimize performance:

  • Add image optimization with next/image
  • Implement ISR with specific revalidation times
  • Add caching headers

Ready for more? Check out our complete Next.js blog tutorial series.


Related Posts:

Share this post:

Related posts