In this blog post, we'll build an imaginary Mira Carry, a small luxury handbag storefront website. We will manage marketing content in Storyblok, and Next.js will render the shop, blog, and a demo checkout. The finished Mira Carry home page, rendered from a Storyblok home story.
Note:
- This tutorial assumes you are comfortable with React and the Next.js App Router. Basic knowledge of Storyblok is required.
This tutorial has been tested with the following package versions:
| Package | Version |
|---|---|
next |
16.1.6 |
react / react-dom
|
^19.2.4 |
@storyblok/react |
^5.4.22 |
If you want to explore the finished project first, open the live demo or clone GitHub repo
Table of contents
- What we'll build
- APIs we use
- How Storyblok works with Next.js
- Set up the project and render a story
- Fetch a single story on dynamic routes
- Build the product catalog with folder queries
- Build category pages and product detail
- Build the blog feed and resolve author relations
- Enable draft mode and Visual Editor preview
- Product images and the shop grid
- Cart and demo checkout
- Optional: seed content with the Management API
- How the architecture fits together
- Wrapping up
- Additional resources
Overview
Storyblok lets you manage content separately from your code repo. In this tutorial, we'll use that to build a handbag website's content:
- Home page modules
- Catalogue across four categories, product detail pages,
- Blog with author relations, and draft preview for editors.
Editors update content in Storyblok. Next.js fetches that content through the Content Delivery API and renders React components.
We'll start by setting up the Next.js project and rendering a story from Storyblok. Then we'll fetch stories on dynamic routes, list products with folder queries, resolve blog author relations, and add draft mode for the Visual Editor.
By the end, you'll have:
- A home page built from nested bloks (
hero,category_strip,product_grid, and more) - A shop catalogue of products under
bags/,purses/,wallets/, andaccessories/ - Category landings and product detail routes
- A blog index and articles with resolved authors
- Draft Mode so editors can preview unpublished changes
- A client-side cart and demo checkout (Next.js only — not Storyblok APIs)
APIs used in this tutorial
We'll talk to Storyblok through @storyblok/react and its apiPlugin. That client uses the Content Delivery API (CDN) v2. Optional seed scripts use the Management API v1.
Content Delivery API v2
Base URL (EU): https://api.storyblok.com/v2/cdn/…
Auth: STORYBLOK_DELIVERY_API_TOKEN (Preview or Public token)
| Endpoint | Method | What we use it for | Parameters we pass |
|---|---|---|---|
/cdn/stories/{slug} |
GET | Home, about, product detail, category pages, articles |
version, resolve_relations
|
/cdn/stories |
GET | Blog index, shop catalog |
version, starts_with, sort_by, is_startpage, per_page
|
Docs: Content Delivery API
GET /cdn/stories/{slug}
Fetches one story by full slug (home, bags/linen-tote, blog/priya-commute-tote, and so on):
Code example
await storyblokApi.get(`cdn/stories/${fullSlug}`, {
version, // 'draft' | 'published'
resolve_relations: 'article.author',
});
You'll see this pattern in:
-
src/app/[[...slug]]/page.js— catch-all pages -
src/app/[category]/page.js— category landings (shop/bags, and so on) src/app/about/page.js
GET /cdn/stories
Fetches multiple stories under a folder prefix.
Blog index (newest first):
Code example
await storyblokApi.get('cdn/stories', {
version,
starts_with: 'blog/',
is_startpage: 0,
sort_by: 'first_published_at:desc',
});
File: src/app/blog/page.js
Shop catalogue (one request per category folder):
Code example
await api.get('cdn/stories', {
version,
starts_with: 'bags/', // also purses/, wallets/, accessories/
is_startpage: 0,
per_page: 100,
});
File: src/lib/products.js (fetchAllProducts())
| Parameter | Values we use | Why |
|---|---|---|
version |
draft or published
|
Draft Mode / Visual Editor vs production |
starts_with |
blog/, bags/, … |
Limit results to a folder |
sort_by |
first_published_at:desc |
Blog ordering |
is_startpage |
0 |
Skip folder index stories in lists |
per_page |
100 |
Catalog page size |
resolve_relations |
article.author |
Inline the author story on articles |
After a fetch with relations, applyResolvedRelations() in src/lib/storyblok.js swaps author UUIDs for full story objects.
Management API v1 (seed scripts only)
Base URL: https://mapi.storyblok.com/v1/spaces/{space_id}/…
Auth: STORYBLOK_MANAGEMENT_TOKEN
| Script | What it does |
|---|---|
scripts/seed-full-catalog.mjs |
Creates product stories and category pages |
scripts/fix-bag-images-and-stories.mjs |
Updates blog copy and CMS asset URLs |
scripts/upload-storyblok-images.mjs |
Uploads images to Storyblok assets |
Docs: Management API
What we don't use
| API / feature | Notes |
|---|---|
| GraphQL API | We use REST through @storyblok/react
|
| Commerce/checkout APIs | Checkout is a demo form under src/app/checkout/
|
| Web-hooks in app code | ISR (revalidate = 60) refreshes cached pages |
How Storyblok works with Next.js
Before diving in, let's first understand how all the pieces fit together. In Storyblok, content is made of blocks (bloks) and stories. Each story has a content.component type, for example page, product, or article.
The Demo_2 space: content types, nestable blocks, and how the home story nests bloks in body.
In our Next.js app, each blok maps to two things:
- A React component (such as
Hero.jsxorProductGrid.jsx) - An entry in the
storyblokInit({ components: { … } })registry insrc/lib/storyblok.js
At request time, a Server Component calls storyblokApi.get(…) on the Content Delivery API. Storyblok returns JSON for the story and its nested bloks. <StoryblokStory story={story} /> walks that tree and renders the matching React component for each blok.
A typical home story looks like this:
page (home)
└── body[]
├── hero
├── category_strip
├── product_grid
├── testimonial
└── story_strip
Set up the project and render a story
Install the project and render a story from Storyblok so we know that the setup works.
1. Clone and install
git clone https://github.com/meharshit/storyblok-interview-showcase.git
cd storyblok-interview-showcase
npm install
cp .env.example .env.local
2. Add your delivery token
In .env.local:
STORYBLOK_DELIVERY_API_TOKEN=your_preview_token
STORYBLOK_REGION=eu
STORYBLOK_API_BASE_URL=https://api.storyblok.com
DRAFT_SECRET=dev-draft-secret
You can find the Preview token under Storyblok --> space Demo_2 --> Settings --> Access tokens.
Tip: If the Blueprint wizard blocks Settings, skip the wizard and open the space settings URL directly, then copy the Preview token.
3. Initialise the SDK
In our repo, the file src/lib/storyblok.js registers blok components and configures the CDN client:
import { apiPlugin, storyblokInit } from '@storyblok/react/rsc';
export const getStoryblokApi = storyblokInit({
accessToken: process.env.STORYBLOK_DELIVERY_API_TOKEN,
use: [apiPlugin],
components: {
page: Page,
hero: Hero,
product_grid: ProductGrid,
category_strip: CategoryStrip,
// …
},
apiOptions: {
region: process.env.STORYBLOK_REGION || 'eu',
},
});
apiPlugin is what gives you storyblokApi.get('cdn/stories/…') against Content Delivery API v2.
4. Run the dev server
npm run dev
Open http://localhost:3000/. The catch-all route loads the home story and renders it — the same layout you saw in the screenshot at the top of this tutorial.
Fetch a single story on dynamic routes
File in the repo: src/app/[[...slug]]/page.js
| URL path | Story slug sent to the CDN |
|---|---|
/ |
home |
/about |
about |
/bags/linen-tote |
bags/linen-tote |
/blog/priya-commute-tote |
blog/priya-commute-tote |
The core fetch looks like this:
const version = await getStoryVersion();
const { data } = await storyblokApi.get(`cdn/stories/${fullSlug}`, {
version,
resolve_relations: ARTICLE_RELATIONS, // 'article.author'
});
const story = applyResolvedRelations(data.story, data.rels);
return <StoryblokStory story={story} />;
getStoryVersion() returns draft when Draft Mode is on (and during local development), and published in production unless Draft Mode is enabled.
Caching uses export const revalidate = 60, so pages can refresh about every minute without a full redeploy.
Build the product catalog with folder queries
The shop page (/shop) lists product stories from four folder prefixes. It calls GET /cdn/stories once per category, there's no custom catalog endpoint.
Shop all: search, category filters, and product cards sourced from Storyblok folder queries.
File: src/lib/products.js
const prefixes = ['bags/', 'purses/', 'wallets/', 'accessories/'];
for (const prefix of prefixes) {
const { data } = await api.get('cdn/stories', {
version,
starts_with: prefix,
is_startpage: 0,
per_page: 100,
});
// keep stories where content.component === 'product'
}
Each product story has fields such as title, price, description, and category. The shop UI turns them into cards with search and filters.
Build category pages and product detail
Category landing pages
Routes: /bags, /purses, /wallets, /accessories
File: src/app/[category]/page.js
Category landings are Storyblok page stories (for example shop/bags) with their own bloks.
| Route | Story slug |
|---|---|
/bags |
shop/bags |
/purses |
shop/purses |
/wallets |
shop/wallets |
/accessories |
shop/accessories |
await storyblokApi.get(`cdn/stories/${page.story}`, { version });
Those stories are page types. Their body can include product_grid and other bloks.
Product detail
Product URLs use the catch-all route. For example, /purses/chain-shoulder becomes:
GET /cdn/stories/purses/chain-shoulder
Product detail: title, price, and description come from the Storyblok product story.
The story’s content.component is product. Product.jsx renders it and handles add-to-cart.
Build the blog feed and resolve author relations
Blog index
File: src/app/blog/page.js
The blog index lists article stories under blog/, sorted by publish date.
await storyblokApi.get('cdn/stories', {
version,
starts_with: 'blog/',
is_startpage: 0,
sort_by: 'first_published_at:desc',
});
Keep stories where content.component === 'article', then link each card to /{full_slug}.
Article and author
Articles store author as a Stories field that points at authors/{slug}. When you fetch a single article, pass:
resolve_relations: 'article.author'
Storyblok returns related authors in data.rels. applyResolvedRelations() replaces the UUID with the full author object so Article.jsx can show name, bio, and avatar.
That relation string lives as ARTICLE_RELATIONS = 'article.author' in src/lib/storyblok.js.
Enable draft mode and Visual Editor preview
The Visual Editor needs the frontend to request version=draft. We'll use Next.js Draft Mode and a small API route for that.
Draft entry route
File: src/app/api/draft/route.js
GET /api/draft?slug=home&secret=YOUR_DRAFT_SECRET
The route validates an optional DRAFT_SECRET, calls draftMode().enable(), and redirects to the story path. To leave draft mode, hit GET /api/disable-draft.
Visual Editor URL
In Storyblok space settings, set the preview URL to your deployed site, or to:
https://your-site.vercel.app/api/draft?slug=home&secret=YOUR_DRAFT_SECRET
When an editor opens a story in the Visual Editor, Storyblok loads that preview URL so they can click components bridged with storyblokEditable.
Product images and the shop grid
For this demo, each story slug maps to a verified JPG in public/products/ through `src/lib/images.js. It still has Storyblok asset fields for later use.
js
export function resolveImageSrc(_image, { slug } = {}) {
return imagePathForSlug(slug || '');
}
For example:
js
'purses/clara-clutch': '/products/burgundy-gray-crescent.jpg',
The CMS still owns copy and structure; the frontend keeps product photography consistent. To change an image, add a file under public/products/ and update the slug map.
Cart and demo checkout
Cart state lives in React context (src/components/cart/CartProvider.jsx). It isn't stored in Storyblok.
Checkout page is a just UI, we have integrated any real payment method. This tutorial is focused on the Content Delivery API.
| Feature | Implementation |
|---|---|
| Add to bag | Client state keyed by product slug |
| Cart drawer | CartDrawer.jsx |
| Checkout |
/checkout — demo card form, no payment API |
Optional: seed content with the Management API
If space Demo_2 is empty, you can seed it with a Management token:
export STORYBLOK_MANAGEMENT_TOKEN=sb_pat_...
export STORYBLOK_SPACE_ID=
node scripts/seed-full-catalog.mjs
The script creates stories with POST / PATCH on https://mapi.storyblok.com/v1/spaces/{id}/stories. Product photos on the site still come from public/products/ unless you also run upload-storyblok-images.mjs.
Caution:
Never put the Management API token in Vercel or commit it to Git. Use it locally for seeding only.
How the architecture fits together
By now you've built each piece of Mira Carry. So, let's understand them together.
The problem we solve in our demo
Marketing people can change the hero, featured products, and blog posts without asking a developer for a deploy every time.
The approach
We have used a headless CMS (Storyblok) for content and Next.js for the storefront. Marketing people work in Storyblok UI. The site reads JSON from the Content Delivery API and renders React components. Content and presentation stay separate.
Content is managed in Storyblok, delivered over the CDN API, and rendered by Next.js.
The content model
The Storyblok space is structured so editors can compose pages without touching code:
| Content | How it's modeled |
|---|---|
| Home, about, category landings |
page stories with a body field of nestable bloks (hero, category_strip, product_grid, …) |
| Products |
product stories in folders (bags/, purses/, wallets/, accessories/) |
| Blog |
article stories under blog/, each linked to an author story |
That model is what makes the Visual Editor useful: editors nest and reorder bloks; the frontend already knows how to render each one.
How we fetch content
At runtime the app only talks to the Content Delivery API v2:
| Need | API call |
|---|---|
| One page, product, or article | GET /cdn/stories/{slug} |
| Shop catalog or blog index |
GET /cdn/stories with starts_with (for example bags/ or blog/) |
Auth uses STORYBLOK_DELIVERY_API_TOKEN. We pass version (draft or published) and, for articles, resolve_relations: 'article.author'.
The Management API is optional and local-only — seed scripts create stories; the live site never calls it.
How we render content
In src/lib/storyblok.js, storyblokInit registers every blok technical name to a React component:
react
components: {
hero: Hero,
product_grid: ProductGrid,
product: Product,
article: Article,
//
}
When Next.js fetches a story, <StoryblokStory /> walks the JSON tree. A blok with component: "hero" becomes <Hero /> with that blok's fields as props. If the Storyblok name and the registry key don't match, that block won't render.
How preview works
Editors need to see unpublished changes; visitors must only see published content.
-
/api/draftenables Next.js Draft Mode (optionally protected byDRAFT_SECRET). -
getStoryVersion()returnsdraftorpublished. - Every Storyblok fetch uses that
version. - The Visual Editor preview URL can point at the draft endpoint so clicks in Storyblok load unpublished content.
Wrapping up
You've now got a headless storefront that uses:
- Content Delivery API v2 for runtime reads
-
@storyblok/reactto map bloks to components - Relations for blog authors
- Draft Mode for editor preview
- ISR for production caching
Search, cart, and checkout stay in Next.js. They sit alongside the CMS without needing extra Storyblok APIs.








Top comments (0)