Stage 2 of 5

Ship a Site on Cosmic with Next.js

6 min readLesson 4 · Stage 2
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-posts is 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

  1. Run npx create-next-app@latest my-site --typescript --app --tailwind

  2. Install the SDK with npm install @cosmicjs/sdk

  3. Put your bucket slug and read key in .env.local

  4. Add lib/cosmic.ts with the client from Step 3

  5. Build app/blog/page.tsx and confirm your posts list at /blog

  6. Add app/blog/[slug]/page.tsx with generateStaticParams

  7. Deploy with npx vercel, add the two environment variables, then npx 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