Ship a Site on Cosmic with Next.js

Build a blog on Next.js that reads from your Cosmic bucket. Covers the SDK, lean queries with props, static generation with generateStaticParams, and deploying to Vercel.
This lesson takes the content sitting in your Cosmic bucket and puts it on a deployed Next.js site. By the end you will have a blog index and individual post pages, statically generated, running on Vercel and reading from your bucket.
This is a developer lesson. It assumes you can run commands in a terminal and are comfortable with React.
Before You Start
-
Node.js 18 or later
-
A Cosmic bucket with a content type that has a few published objects in it.
blog-postsis used throughout; substitute your own slug -
A body field on that type. New object types come with only a title and a slug, so add a Markdown metafield with the key
content, which is what the examples below read -
Your bucket slug and read key, from Settings > API keys in your bucket
-
A Vercel account for the final step
Step 1: Create the Project
npx create-next-app@latest my-site --typescript --app --tailwind cd my-site npm install @cosmicjs/sdk
The --app flag matters. This lesson uses the App Router, and the data fetching is different from the older Pages Router.
Step 2: Add Your Keys
Create .env.local in the project root:
COSMIC_BUCKET_SLUG=your-bucket-slug COSMIC_READ_KEY=your-read-key
There is no NEXT_PUBLIC_ prefix, which is deliberate. These variables stay on the server and never reach the browser.
Your bucket also has a write key. It is not needed here, and it should never appear in a frontend project. Anything with a write key can modify your content.
Step 3: Create the Client
Add 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, });
One client, imported wherever you need it.
Step 4: Build the Index Page
Create app/blog/page.tsx:
import Link from 'next/link'; import { cosmic } from '@/lib/cosmic'; export const revalidate = 60; export default async function BlogIndex() { const { objects: posts } = await cosmic.objects .find({ type: 'blog-posts' }) .props(['title', 'slug', 'metadata']) .sort('-created_at'); return ( <main className="mx-auto max-w-2xl p-8"> <h1 className="text-3xl font-bold">Blog</h1> <ul className="mt-6 space-y-3"> {posts.map((post: any) => ( <li key={post.slug}> <Link href={`/blog/${post.slug}`} className="text-blue-600 hover:underline"> {post.title} </Link> </li> ))} </ul> </main> ); }
Run npm run dev and open /blog.
Note what is absent. There is no useEffect, no loading state, and no API route in the middle. The component is an async server function, so the fetch runs on the server and the browser receives finished HTML.
export const revalidate = 60 tells Next.js to regenerate the page at most once a minute. Publishing in Cosmic updates the site without a redeploy.
Step 5: Fetch Only What You Render
.props() controls which fields come back. Without it you get the entire object, including every metafield, on every post.
// Returns everything on every post .find({ type: 'blog-posts' }) // Returns three fields .find({ type: 'blog-posts' }).props(['title', 'slug', 'metadata'])
On a list page you rarely need metadata at all. If your cards show a title, a slug, and a thumbnail, ask for exactly that:
.props(['title', 'slug', 'metadata.featured_image'])
Dotted paths work, so you can reach into metadata and pull one field instead of the whole object. On a bucket with long post bodies this is the difference between a fast list page and a slow one.
Step 6: Add Dynamic Post Pages
Create app/blog/[slug]/page.tsx:
import { notFound } from 'next/navigation'; import { cosmic } from '@/lib/cosmic'; export const revalidate = 60; export async function generateStaticParams() { const { objects } = await cosmic.objects .find({ type: 'blog-posts' }) .props(['slug']); return objects.map((post: any) => ({ slug: post.slug })); } export default async function PostPage({ params, }: { params: Promise<{ slug: string }>; }) { const { slug } = await params; try { const { object: post } = await cosmic.objects .findOne({ type: 'blog-posts', slug }) .props(['title', 'slug', 'metadata']) .depth(1); return ( <article className="mx-auto max-w-2xl p-8"> <h1 className="text-3xl font-bold">{post.title}</h1> <div className="mt-6">{post.metadata.content}</div> </article> ); } catch { notFound(); } }
Four things in here are worth calling out.
metadata.content is the Markdown metafield you added, and printing it directly renders the raw markdown. On a real site you would run it through a renderer such as react-markdown. Both the Markdown and Rich Text metafield types return markdown, so this applies either way.
generateStaticParams is the App Router's way of listing which dynamic routes to build ahead of time. If you have used the Pages Router, this replaces getStaticPaths. They are not interchangeable, and getStaticPaths in an App Router project simply does nothing.
params is a Promise and has to be awaited. This changed in Next.js 15. If you are following an older tutorial that reads params.slug directly, that is why it breaks.
.depth(1) resolves related objects. If your post has an author relationship, depth 1 returns the full author object instead of just its ID. Leave it off when you do not need it, since it costs response size.
A Tip for Empty Results
This is the single most common thing to trip over on a first Cosmic build.
When a query matches nothing, the SDK does not hand back an empty array. It throws a 404:
No objects found for your query in bucket 'your-bucket-slug'
Both find() and findOne() behave this way. So a page for a slug that does not exist, or a list page for a content type with no published objects yet, will crash rather than render empty.
That is why the post page above wraps the call in try/catch and falls through to notFound(). For a list page, do the same and render an empty state:
let posts = []; try { const res = await cosmic.objects .find({ type: 'blog-posts' }) .props(['title', 'slug']); posts = res.objects; } catch { // No posts published yet }
If your build fails with a 404 from Cosmic and the query looks right, check whether anything is actually published. Draft objects are not returned by default.
Step 7: Deploy
From the project root:
npx vercel
Answer the prompts to link the project. The first deploy will fail to fetch content, because Vercel does not have your environment variables yet.
Add them:
npx vercel env add COSMIC_BUCKET_SLUG npx vercel env add COSMIC_READ_KEY
Then ship it:
npx vercel --prod
Your site is live and pulling from your bucket.
Going Further
Right now the site refreshes content on a 60 second timer. You can make it immediate instead: Cosmic can fire a webhook when an object is published, and Next.js can revalidate a specific path on demand when that webhook arrives. That turns a publish in the dashboard into an updated page within seconds, with no timer and no rebuild.
The .status() method is the other thing worth knowing about, since it lets you request unpublished objects for a preview environment while production keeps serving only published content.
Do This in Your Project
-
Run
npx create-next-app@latest my-site --typescript --app --tailwind -
Install the SDK with
npm install @cosmicjs/sdk -
Put your bucket slug and read key in
.env.local -
Add
lib/cosmic.tswith the client from Step 3 -
Build
app/blog/page.tsxand confirm your posts list at/blog -
Add
app/blog/[slug]/page.tsxwithgenerateStaticParams -
Deploy with
npx vercel, add the two environment variables, thennpx vercel --prod
If you get a 404 from Cosmic at any point, re-read the tip above before debugging anything else.
Up Next
Lesson 5: Ship a Site on Cosmic with Astro
Lesson 5 builds the same site in Astro, which handles data fetching and static routes differently. Skip it if Next.js is your framework.
Up next
Ship a Site on Cosmic with Astro