DEV Community

Cover image for Build a Blog with Next.js 16 and Cosmic CMS
Tony Spiro
Tony Spiro

Posted on • Originally published at cosmicjs.com

Build a Blog with Next.js 16 and Cosmic CMS

Next.js 16 shipped in October 2025 with Turbopack stable, React Compiler support, Cache Components, and a redesigned caching model. If you are starting a new blog or content site today, this is the stack to use.

This tutorial walks you through building a production-ready blog from scratch using Next.js 16 and Cosmic as your headless CMS. You will set up a content model in Cosmic, fetch posts with the TypeScript SDK, render them in Next.js 16 App Router server components, and deploy to Vercel.

Estimated time: 30 minutes

What You Will Build

A blog with a homepage listing posts, individual post pages, and tag filtering — all powered by Cosmic's REST API and the TypeScript SDK.

Prerequisites

  • Node.js 20+
  • A free Cosmic account (no credit card required)
  • A Vercel account for deployment (free tier is fine)
  • Basic familiarity with React and TypeScript

Step 1: Create Your Cosmic Bucket

Log into Cosmic and create a new bucket. Go to Object Types and create a new type called blog-posts. Add the following metafields:

  • title — Text (built-in)
  • slug — Text (built-in, auto-generated)
  • content — Markdown (long-form post body)
  • excerpt — Textarea (short summary, 160 chars max)
  • cover_image — File (featured image)
  • published_date — Date
  • tags — Text (comma-separated)

Create 2-3 sample blog posts and publish them.

Step 2: Scaffold a Next.js 16 App

npx create-next-app@latest my-blog
cd my-blog
Enter fullscreen mode Exit fullscreen mode

When prompted: use the App Router, TypeScript, and Tailwind CSS.

Install the Cosmic TypeScript SDK

npm install @cosmicjs/sdk
Enter fullscreen mode Exit fullscreen mode

Configure Environment Variables

Create a .env.local file:

COSMIC_BUCKET_SLUG=your-bucket-slug
COSMIC_READ_KEY=your-read-key
Enter fullscreen mode Exit fullscreen mode

Step 3: Create the Cosmic Client

Create lib/cosmic.ts:

import { createBucketClient } from '@cosmicjs/sdk'

export const cosmic = createBucketClient({
  bucketSlug: process.env.COSMIC_BUCKET_SLUG!,
  readKey: process.env.COSMIC_READ_KEY!,
})
Enter fullscreen mode Exit fullscreen mode

Step 4: Define Your TypeScript Types

Create types/post.ts:

export interface Post {
  id: string
  title: string
  slug: string
  metadata: {
    content: string
    excerpt: string
    cover_image: { imgix_url: string }
    published_date: string
    tags: string
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 5: Fetch Posts from Cosmic

Create lib/posts.ts:

import { cosmic } from './cosmic'
import { Post } from '@/types/post'

export async function getPosts(): Promise<Post[]> {
  const { objects } = await cosmic.objects
    .find({ type: 'blog-posts' })
    .props('id,title,slug,metadata')
    .sort('-created_at')
    .status('published')
  return objects as Post[]
}

export async function getPost(slug: string): Promise<Post> {
  const { object } = await cosmic.objects
    .findOne({ type: 'blog-posts', slug })
    .props('id,title,slug,metadata')
    .status('published')
  return object as Post
}
Enter fullscreen mode Exit fullscreen mode

Step 6: Build the Homepage

Replace app/page.tsx:

import { getPosts } from '@/lib/posts'
import Link from 'next/link'
import Image from 'next/image'

export const revalidate = 60

export default async function HomePage() {
  const posts = await getPosts()
  return (
    <main className="max-w-3xl mx-auto py-12 px-4">
      <h1 className="text-4xl font-bold mb-8">Blog</h1>
      <div className="space-y-8">
        {posts.map((post) => (
          <article key={post.id}>
            {post.metadata.cover_image && (
              <Image
                src={`${post.metadata.cover_image.imgix_url}?w=800&auto=format`}
                alt={post.title}
                width={800}
                height={400}
                className="rounded-lg mb-4"
              />
            )}
            <h2 className="text-2xl font-semibold">
              <Link href={`/blog/${post.slug}`}>{post.title}</Link>
            </h2>
            <p className="text-gray-600 mt-2">{post.metadata.excerpt}</p>
          </article>
        ))}
      </div>
    </main>
  )
}
Enter fullscreen mode Exit fullscreen mode

Step 7: Build the Individual Post Page

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

import { getPosts, getPost } from '@/lib/posts'
import ReactMarkdown from 'react-markdown'

export async function generateStaticParams() {
  const posts = await getPosts()
  return posts.map((post) => ({ slug: post.slug }))
}

export default async function PostPage({
  params,
}: {
  params: Promise<{ slug: string }>
}) {
  const { slug } = await params  // Next.js 16: params is a Promise
  const post = await getPost(slug)
  return (
    <main className="max-w-3xl mx-auto py-12 px-4">
      <h1 className="text-4xl font-bold mb-8">{post.title}</h1>
      <ReactMarkdown>{post.metadata.content}</ReactMarkdown>
    </main>
  )
}
Enter fullscreen mode Exit fullscreen mode

Important Next.js 16 note: params in dynamic route pages is now a Promise. You must await params before accessing its properties. This is a breaking change from Next.js 14.

Step 8: On-Demand Revalidation

Create 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-cosmic-secret')
  if (secret !== process.env.COSMIC_WEBHOOK_SECRET) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  }
  revalidatePath('/blog/[slug]', 'page')
  revalidatePath('/')
  return NextResponse.json({ revalidated: true })
}
Enter fullscreen mode Exit fullscreen mode

In Cosmic, go to Bucket Settings > Webhooks and add a webhook pointing to your /api/revalidate endpoint. Trigger it on publish events.

Step 9: Deploy to Vercel

npx vercel
Enter fullscreen mode Exit fullscreen mode

Or push to GitHub and import at vercel.com. Add your environment variables in Vercel's dashboard.

Step 10: Preview Mode for Drafts (Bonus)

Enable draft previews by setting status: 'any' on the Cosmic client for preview routes. Wire this up to Next.js Draft Mode for a live editor preview experience.

What You Built

  • Next.js 16 blog with App Router and server components
  • Content managed in Cosmic (no database required)
  • Static generation with ISR for fast page loads
  • On-demand revalidation via webhooks
  • Automatic image optimization via Cosmic's imgix CDN
  • Deployed to Vercel in minutes

Why Cosmic for Next.js 16

  • Sub-100ms API responses — Cosmic's CDN caches content globally, keeping TTFB low at high traffic
  • Structured content model — your schema maps cleanly to TypeScript interfaces
  • No backend to maintain — Cosmic handles hosting, scaling, and backups
  • Free tier to start — 1 bucket, 1,000 objects, 100K cached API requests/month, no credit card required

Next Steps

Top comments (0)