Back to Blog
Blog

Build a Blog with Next.js 16 and Cosmic CMS

Cosmic AI's avatar

Cosmic AI

May 4, 2026

Hero image

Next.js 16 shipped in October 2025 with Turbopack stable, React Compiler support, Cache Components, and a redesigned caching model built around use cache. 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 the whole thing to Vercel. The estimated time is 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 @cosmicjs/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. Give it a name like my-nextjs-blog.

Once inside your bucket, you will see the dashboard. Cosmic organizes content into Object Types (your content models) and Objects (individual content entries). For this tutorial, you need one Object Type: blog-posts.

Set Up the Blog Post Content Model

In your bucket, go to Object Types and create a new type called Blog Posts. Add the following metafields:

FieldTypeNotes
titleTextBuilt-in, always present
slugTextBuilt-in, auto-generated
contentMarkdownLong-form post body
excerptTextareaShort summary, 160 chars max
cover_imageFile (image)Featured image
published_dateDatePost date
tagsTextComma-separated tags

Save the Object Type. Now create 2–3 sample blog posts so you have content to fetch. Publish them.

Get Your API Keys

In your Cosmic dashboard, go to Bucket Settings > API Keys. You need:

  • Bucket Slug — e.g., my-nextjs-blog-production
  • Read Key — used for fetching published content on the frontend

Keep these handy. You will add them to your .env.local in a moment.


Step 2: Scaffold a Next.js 16 App

Open your terminal and run:

npx create-next-app@latest my-cosmic-blog --typescript --tailwind --app cd my-cosmic-blog

When prompted:

  • Use the App Router (say yes)
  • TypeScript: yes
  • Tailwind CSS: yes (optional, but the examples below use it)

Next.js 16 uses the App Router by default. The app/ directory is your routing root. All components in this directory are React Server Components by default, which means they can fetch data directly on the server with no client-side waterfall.

Install the Cosmic TypeScript SDK

npm install @cosmicjs/sdk

This is the official SDK. Do not use the older cosmicjs package.

Configure Environment Variables

Create a .env.local file in the project root:

COSMIC_BUCKET_SLUG=your-bucket-slug COSMIC_READ_KEY=your-read-key

Add .env.local to your .gitignore if it is not already there.


Step 3: Create the Cosmic Client

Create a file at lib/cosmic.ts:

import { createBucketClient } from '@cosmicjs/sdk'; export const cosmic = createBucketClient({ bucketSlug: process.env.COSMIC_BUCKET_SLUG as string, readKey: process.env.COSMIC_READ_KEY as string, });

That is your entire data layer. The createBucketClient function returns a typed client scoped to your bucket. All fetch methods are available on it.


Step 4: Define Your TypeScript Types

Create types/post.ts:

export type Post = { id: string; title: string; slug: string; metadata: { content: string; excerpt: string; cover_image?: { imgix_url: string; }; published_date: string; tags?: string; }; };

This mirrors the shape Cosmic returns for your blog post objects.


Step 5: Fetch Posts from Cosmic

Create lib/posts.ts:

import { cosmic } from './cosmic'; import type { Post } from '@/types/post'; export async function getAllPosts(): Promise<Post[]> { const { objects } = await cosmic.objects .find({ type: 'blog-posts' }) .props('id,title,slug,metadata') .sort('-metadata.published_date') .status('published'); return objects as Post[]; } export async function getPostBySlug(slug: string): Promise<Post | null> { try { const { object } = await cosmic.objects .findOne({ type: 'blog-posts', slug }) .props('id,title,slug,metadata') .status('published'); return object as Post; } catch { return null; } }

A few things to note:

  • .props() limits the fields returned, keeping API responses lean.
  • .sort('-metadata.published_date') sorts newest first. The - prefix means descending.
  • .status('published') ensures you only fetch published posts (not drafts).
  • The SDK is fully typed. Your editor will autocomplete method chains.

Step 6: Build the Homepage — Post Listing

Replace the contents of app/page.tsx:

import Link from 'next/link'; import Image from 'next/image'; import { getAllPosts } from '@/lib/posts'; import type { Post } from '@/types/post'; export const revalidate = 60; // ISR: revalidate every 60 seconds export default async function HomePage() { const posts = await getAllPosts(); return ( <main className="max-w-3xl mx-auto px-4 py-12"> <h1 className="text-4xl font-bold mb-8">Blog</h1> <div className="space-y-10"> {posts.map((post: Post) => ( <article key={post.id} className="border-b pb-10"> {post.metadata.cover_image && ( <div className="relative w-full h-48 mb-4 rounded-lg overflow-hidden"> <Image src={`${post.metadata.cover_image.imgix_url}?w=800&auto=format`} alt={post.title} fill className="object-cover" /> </div> )} <h2 className="text-2xl font-semibold mb-2"> <Link href={`/blog/${post.slug}`} className="hover:underline"> {post.title} </Link> </h2> <p className="text-gray-600 mb-3">{post.metadata.excerpt}</p> <p className="text-sm text-gray-400"> {new Date(post.metadata.published_date).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric', })} </p> </article> ))} </div> </main> ); }

Key Next.js 16 patterns here:

  • export const revalidate = 60 enables Incremental Static Regeneration (ISR). The page is statically generated at build time and refreshed every 60 seconds in the background. No need for getStaticProps or getServerSideProps.
  • The component is async and awaits data directly. No useEffect, no loading states on the server.
  • Image from next/image handles optimization automatically. The ?w=800&auto=format query appended to Cosmic's imgix URL tells imgix to resize and optimize on the fly.

Step 7: Build the Individual Post Page

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

import { notFound } from 'next/navigation'; import Image from 'next/image'; import ReactMarkdown from 'react-markdown'; import { getAllPosts, getPostBySlug } from '@/lib/posts'; type Props = { params: Promise<{ slug: string }>; // Next.js 16: params is a Promise }; // Generate static paths at build time export async function generateStaticParams() { const posts = await getAllPosts(); return posts.map((post) => ({ slug: post.slug })); } // Generate page metadata dynamically export async function generateMetadata({ params }: Props) { const { slug } = await params; const post = await getPostBySlug(slug); if (!post) return {}; return { title: post.title, description: post.metadata.excerpt, openGraph: { title: post.title, description: post.metadata.excerpt, images: post.metadata.cover_image ? [{ url: `${post.metadata.cover_image.imgix_url}?w=1200&auto=format` }] : [], }, }; } export default async function PostPage({ params }: Props) { const { slug } = await params; const post = await getPostBySlug(slug); if (!post) notFound(); return ( <main className="max-w-3xl mx-auto px-4 py-12"> <h1 className="text-4xl font-bold mb-4">{post.title}</h1> <p className="text-sm text-gray-400 mb-8"> {new Date(post.metadata.published_date).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric', })} </p> {post.metadata.cover_image && ( <div className="relative w-full h-64 mb-8 rounded-xl overflow-hidden"> <Image src={`${post.metadata.cover_image.imgix_url}?w=1200&auto=format`} alt={post.title} fill className="object-cover" priority /> </div> )} <article className="prose prose-lg max-w-none"> <ReactMarkdown>{post.metadata.content}</ReactMarkdown> </article> </main> ); }

Install react-markdown for rendering your Markdown content:

npm install react-markdown

Important Next.js 16 note: In Next.js 16, params in dynamic route pages is a Promise. You must await params before accessing its properties. This is a breaking change from Next.js 14. The @next/codemod tool can migrate your existing code automatically.

generateStaticParams tells Next.js which slugs to pre-render at build time. Any slug not in the list will be rendered on-demand (ISR fallback).


Step 8: On-Demand Revalidation (Webhook)

When an editor publishes or updates a post in Cosmic, you want your site to reflect those changes quickly. Next.js 16 supports on-demand cache revalidation via its Cache API.

Create app/api/revalidate/route.ts:

import { revalidateTag } from 'next/cache'; import { NextRequest, NextResponse } from 'next/server'; export async function POST(request: NextRequest) { const secret = request.headers.get('x-revalidate-secret'); if (secret !== process.env.REVALIDATE_SECRET) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } revalidateTag('blog-posts'); return NextResponse.json({ revalidated: true, timestamp: Date.now() }); }

Add REVALIDATE_SECRET=your-secret-here to your .env.local.

Then, in Cosmic, go to Bucket Settings > Webhooks and add a webhook pointing to https://your-vercel-domain.vercel.app/api/revalidate. Send the secret in a header: x-revalidate-secret: your-secret-here. Trigger it on object.published events.

Now when you publish a post in Cosmic, your Next.js site will revalidate within seconds.


Step 9: Deploy to Vercel

Vercel is the easiest way to deploy Next.js apps. If you haven't already:

npm install -g vercel vercel

Or push your repo to GitHub and import it at vercel.com.

Add your environment variables in Vercel:

  1. Go to your project in the Vercel dashboard.
  2. Open Settings > Environment Variables.
  3. Add:
    • COSMIC_BUCKET_SLUG
    • COSMIC_READ_KEY
    • REVALIDATE_SECRET

Trigger a new deployment. Vercel will build your app, run generateStaticParams, and pre-render all your blog posts as static HTML. Your blog is now live.


Step 10: Preview Mode for Drafts (Bonus)

Cosmic supports previewing draft content before publishing. To enable this, set .status('any') on the Cosmic client:

export async function getPreviewPost(slug: string) { const previewClient = createBucketClient({ bucketSlug: process.env.COSMIC_BUCKET_SLUG as string, readKey: process.env.COSMIC_READ_KEY as string, }); const { object } = await previewClient.objects .findOne({ type: 'blog-posts', slug }) .props('id,title,slug,metadata') .status('any'); // fetch drafts too return object; }

Wire this up to a Next.js Draft Mode route handler to give your editors a live preview experience.


What You Built

Here is what you have at this point:

  • A 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

The full source for this tutorial is structured to extend easily. Add a tag filter, an author page, or a search endpoint using Cosmic's REST API — the SDK supports filtering, sorting, and full-text search out of the box.


Why Cosmic for Next.js 16

Cosmic pairs well with Next.js 16 for a few concrete reasons:

Sub-100ms API responses. Cosmic's CDN caches your content globally. Your server components fetch fast, which keeps your Time to First Byte (TTFB) low even at high traffic.

Structured content model. You define the schema. No rigid blog templates. Your Object Types map cleanly to TypeScript interfaces, which makes type-safe data fetching straightforward.

No backend to maintain. Cosmic handles hosting, scaling, and backups. Your team writes content; your Next.js app delivers it.

Free tier to start. The free plan includes 1 bucket, 1,000 objects, and 100K cached API requests per month. No credit card required. You can go from this tutorial to a live blog without spending anything.


Next Steps


Ready to build? Start for free — no credit card required.

Hero image