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
When prompted: use the App Router, TypeScript, and Tailwind CSS.
Install the Cosmic TypeScript SDK
npm install @cosmicjs/sdk
Configure Environment Variables
Create a .env.local file:
COSMIC_BUCKET_SLUG=your-bucket-slug
COSMIC_READ_KEY=your-read-key
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!,
})
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
}
}
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
}
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>
)
}
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>
)
}
Important Next.js 16 note:
paramsin dynamic route pages is now aPromise. You mustawait paramsbefore 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 })
}
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
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
Top comments (0)