Ship a Site on Cosmic with Astro

Build a blog on Astro that reads from your Cosmic bucket. Covers the SDK, lean queries with props, static routes with getStaticPaths, and deploying to Vercel.
This lesson builds a blog on Astro that reads from your Cosmic bucket, then deploys it. By the end you will have an index page and individual post pages, generated as static HTML, live on Vercel.
This is a developer lesson. It covers the same ground as the Next.js lesson, so pick whichever framework you are actually using rather than doing both.
Before You Start
-
Node.js 18 or later
-
A Cosmic bucket with a content type holding a few published objects.
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
npm create astro@latest my-site cd my-site npm install @cosmicjs/sdk
Take the minimal or empty template when prompted. The blog starter ships with its own content collections setup, which you would then have to unpick.
Step 2: Add Your Keys
Create .env in the project root:
COSMIC_BUCKET_SLUG=your-bucket-slug COSMIC_READ_KEY=your-read-key
No PUBLIC_ prefix. In Astro, variables prefixed with PUBLIC_ are exposed to the browser, and these should not be. Your write key does not belong in this project at all.
Step 3: Create the Client
Add src/lib/cosmic.ts:
import { createBucketClient } from '@cosmicjs/sdk'; export const cosmic = createBucketClient({ bucketSlug: import.meta.env.COSMIC_BUCKET_SLUG, readKey: import.meta.env.COSMIC_READ_KEY, });
Astro reads environment variables through import.meta.env, not process.env. This is the most common porting mistake when moving example code over from a Next.js tutorial.
Step 4: Build the Index Page
Create src/pages/blog/index.astro:
--- import { cosmic } from '../../lib/cosmic'; const { objects: posts } = await cosmic.objects .find({ type: 'blog-posts' }) .props(['title', 'slug', 'metadata']) .sort('-created_at'); --- <html lang="en"> <body> <main> <h1>Blog</h1> <ul> {posts.map((post: any) => ( <li> <a href={`/blog/${post.slug}`}>{post.title}</a> </li> ))} </ul> </main> </body> </html>
Run npm run dev and open /blog.
Everything between the --- fences is the frontmatter script. It runs on the server at build time only, never in the browser, which is why it is safe to use your read key there. The part below renders to HTML.
By default Astro builds this to a static file. The fetch happens once when you build, not when a visitor arrives.
Step 5: Fetch Only What You Render
.props() limits the fields in the response. Without it, every post comes back complete, including metafields the page never displays.
// Everything .find({ type: 'blog-posts' }) // Three fields .find({ type: 'blog-posts' }).props(['title', 'slug', 'metadata'])
Dotted paths let you reach a single metafield rather than the whole object:
.props(['title', 'slug', 'metadata.featured_image'])
A list page usually needs a title, a slug, and an image. Asking for full post bodies you are not rendering is the easiest performance mistake to make and the easiest to fix.
Step 6: Add Dynamic Post Pages
Create src/pages/blog/[slug].astro:
--- import { cosmic } from '../../lib/cosmic'; export async function getStaticPaths() { const { objects } = await cosmic.objects .find({ type: 'blog-posts' }) .props(['title', 'slug', 'metadata']) .depth(1); return objects.map((post: any) => ({ params: { slug: post.slug }, props: { post }, })); } const { post } = Astro.props; --- <html lang="en"> <body> <article> <h1>{post.title}</h1> <div>{post.metadata.content}</div> </article> </body> </html>
metadata.content is the Markdown metafield you added. Both the Markdown and Rich Text metafield types return markdown, so a real site would run it through a markdown renderer and then use set:html. Passing markdown straight to set:html renders the source, asterisks and all.
getStaticPaths is correct here. Astro uses it to enumerate the dynamic routes to build. This is worth flagging because the Next.js App Router uses generateStaticParams instead, and the two are not interchangeable.
The useful trick is props in the returned object. Because getStaticPaths already fetched every post, you can hand the whole post to the page through Astro.props and skip a second lookup per page. One request builds the entire blog.
.depth(1) resolves relationships, so an author reference comes back as the full author object rather than an ID.
A Tip for Empty Results
This catches nearly everyone on a first Cosmic build.
When a query matches nothing, the SDK throws a 404 rather than returning an empty array:
No objects found for your query in bucket 'your-bucket-slug'
Both find() and findOne() do this. A content type with nothing published yet will fail your build rather than produce an empty page.
Guard the calls that can legitimately come back empty:
let posts = []; try { const res = await cosmic.objects .find({ type: 'blog-posts' }) .props(['title', 'slug']); posts = res.objects; } catch { // Nothing published yet }
This bites harder in Astro than in Next.js, because the fetch runs at build time. A 404 from Cosmic fails the whole build rather than one page. If npm run build dies on a Cosmic error, check that the content type actually has published objects in it. Drafts are not returned by default.
Step 7: Deploy
A static Astro build deploys to Vercel with no adapter:
npx vercel
Link the project when prompted. The first build will fail on the content fetch, because your environment variables are not there yet.
Add them:
npx vercel env add COSMIC_BUCKET_SLUG npx vercel env add COSMIC_READ_KEY
Then deploy for real:
npx vercel --prod
Because the site is fully static, publishing new content in Cosmic will not change the live site until you rebuild. That is the tradeoff for serving plain HTML, and the next section covers how to handle it.
Going Further
Two ways to keep a static Astro site current. Simplest is a deploy hook: Vercel gives you a URL that triggers a rebuild, and Cosmic can call it with a webhook when an object is published. Publishing then kicks off a rebuild automatically.
If you need pages that update without any rebuild, install @astrojs/vercel and switch the relevant routes to server rendering. You give up some of the speed of static HTML in exchange for always-current content, so it is worth doing per route rather than site-wide.
Do This in Your Project
-
Run
npm create astro@latest my-siteand pick the minimal template -
Install the SDK with
npm install @cosmicjs/sdk -
Put your bucket slug and read key in
.env -
Add
src/lib/cosmic.tsusingimport.meta.env -
Build
src/pages/blog/index.astroand confirm your posts list at/blog -
Add
src/pages/blog/[slug].astrousinggetStaticPaths, passing the post throughprops -
Deploy with
npx vercel, add the two environment variables, thennpx vercel --prod
Run npm run build locally before deploying. Build-time fetching means most errors show up there rather than in the browser.
Up Next
Lesson 6: Model Your Content, Metafields and Schema
Lesson 6 goes back to the dashboard and covers how content types and metafields are structured, and how those choices shape the API responses you have been consuming in this lesson.
Up next
Model Your Content: Metafields and Schema