DEV Community

Cover image for How to Build a Handbag Storefront with Storyblok and Next.js
Harshit Satyaseel
Harshit Satyaseel

Posted on

How to Build a Handbag Storefront with Storyblok and Next.js

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.

Home

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

  1. What we'll build
  2. APIs we use
  3. How Storyblok works with Next.js
  4. Set up the project and render a story
  5. Fetch a single story on dynamic routes
  6. Build the product catalog with folder queries
  7. Build category pages and product detail
  8. Build the blog feed and resolve author relations
  9. Enable draft mode and Visual Editor preview
  10. Product images and the shop grid
  11. Cart and demo checkout
  12. Optional: seed content with the Management API
  13. How the architecture fits together
  14. Wrapping up
  15. 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.

Architecture

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/, and accessories/
  • 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',
});
Enter fullscreen mode Exit fullscreen mode

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',
});
Enter fullscreen mode Exit fullscreen mode

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,
});
Enter fullscreen mode Exit fullscreen mode

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.

Content tree

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:

  1. A React component (such as Hero.jsx or ProductGrid.jsx)
  2. An entry in the storyblokInit({ components: { … } }) registry in src/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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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',
  },
});
Enter fullscreen mode Exit fullscreen mode

apiPlugin is what gives you storyblokApi.get('cdn/stories/…') against Content Delivery API v2.

4. Run the dev server

npm run dev
Enter fullscreen mode Exit fullscreen mode

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} />;
Enter fullscreen mode Exit fullscreen mode

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.

page

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'
}
Enter fullscreen mode Exit fullscreen mode

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

Bags category landing

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 });
Enter fullscreen mode Exit fullscreen mode

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 page

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

Customer stories blog index

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',
});
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

Demo checkout

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.

  1. /api/draft enables Next.js Draft Mode (optionally protected by DRAFT_SECRET).
  2. getStoryVersion() returns draft or published.
  3. Every Storyblok fetch uses that version.
  4. 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/react to 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)