Import CSV Content into Storyblok
Storyblok is the first headless CMS that works for developers & marketers alike.
Content migrations often pass through a CSV file. This tutorial turns such a file into Storyblok stories with one Node.js script, using the Management API client to write to your space and the rich text library to turn Markdown into rich text.
Prerequisites
To follow this tutorial, you need:
- Node.js 22 or later
- A Storyblok space
- A personal access token
Set up the project
Create a project folder and place your CSV in it.
For demonstration purposes, let’s consider the following sample file, posts.csv, with two blog posts, including quoted fields, commas inside values, escaped quotes, and Markdown bodies that span several lines.
title,slug,author,published_at,excerpt,body,image_url
Why We Left Our Legacy CMS,why-we-left-our-legacy-cms,Ana Ruiz,2024-01-15,"A monolith served us for eight years, then stopped.","## The breaking point
Our editors waited 40 seconds for a preview. Our developers waited three weeks for a template change.
We measured both, wrote the numbers on a wall, and started looking for a headless CMS.",https://example.com/images/legacy-cms.jpg
"The ""One Big Table"" Antipattern",the-one-big-table-antipattern,Tomás Klein,2024-02-26,"When every page type shares a single schema, nothing is required.","## Why it happens
A single ""page"" type feels flexible. Then 40 optional fields appear, and editors guess which eight apply.
Split it. Three content types with required fields beat one type with none.",https://example.com/images/big-table.jpg Next, install both dependencies:
npm install @storyblok/management-api-client @storyblok/richtext Create a content type named blog_post with six fields, one per CSV column: title and author as text, published_at as datetime, excerpt as textarea, body as rich text, and image_url as text. The slug column has no field of its own—it becomes the story slug, and the title column becomes the story name as well as the title field.
You can create the content type in the block library, or with the schema package, which defines content types in TypeScript and pushes them to your space.
Store your credentials and the target folder in a .env file. To find the folder ID, list the folders in your space with client.stories.list({ query: { folder_only: true } }).
STORYBLOK_PERSONAL_ACCESS_TOKEN=your-token
STORYBLOK_SPACE_ID=123456
STORYBLOK_FOLDER_ID=234567 Write the script
A CSV field can contain commas, line breaks, and doubled quotes, so splitting on commas loses data. parseCsv() reads the file one character at a time, tracks whether it sits inside a quoted field, and returns one object per row keyed by the column names. The loop then creates a story per row and collects the rows that fail:
import { readFile } from 'node:fs/promises';
import { createManagementApiClient } from '@storyblok/management-api-client';
import { markdownToStoryblokRichtext } from '@storyblok/richtext/markdown-parser';
const client = createManagementApiClient({
personalAccessToken: process.env.STORYBLOK_PERSONAL_ACCESS_TOKEN,
spaceId: Number(process.env.STORYBLOK_SPACE_ID),
region: 'eu', // set to the region of your space
});
const parentId = Number(process.env.STORYBLOK_FOLDER_ID);
if (!Number.isInteger(parentId)) {
throw new Error('Set STORYBLOK_FOLDER_ID to the ID of the target folder');
}
function parseCsv(input) {
const rows = [];
let row = [];
let field = '';
let quoted = false;
for (let index = 0; index < input.length; index++) {
const char = input[index];
if (quoted) {
if (char === '"' && input[index + 1] === '"') {
field += '"';
index++;
} else if (char === '"') {
quoted = false;
} else {
field += char;
}
continue;
}
if (char === '"') {
quoted = true;
} else if (char === ',') {
row.push(field);
field = '';
} else if (char === '\n') {
row.push(field);
rows.push(row);
row = [];
field = '';
} else if (char !== '\r') {
field += char;
}
}
if (field !== '' || row.length > 0) {
row.push(field);
rows.push(row);
}
const [header, ...records] = rows;
return records.flatMap((cells, index) => {
if (cells.every((cell) => cell === '')) {
return [];
}
if (cells.length !== header.length) {
throw new Error(
`Data row ${index + 1}: expected ${header.length} cells, found ${cells.length}`,
);
}
return Object.fromEntries(
header.map((key, position) => [key, cells[position]]),
);
});
}
const csv = await readFile(new URL('./posts.csv', import.meta.url), 'utf8');
const rows = parseCsv(csv);
const failures = [];
for (const row of rows) {
const { error } = await client.stories.create({
body: {
story: {
name: row.title,
slug: row.slug,
parent_id: parentId,
content: {
component: 'blog_post',
title: row.title,
author: row.author,
published_at: `${row.published_at} 00:00`,
excerpt: row.excerpt,
body: markdownToStoryblokRichtext(row.body),
image_url: row.image_url,
},
},
},
});
if (error) {
failures.push({ slug: row.slug, error });
}
}
console.log(`Imported ${rows.length - failures.length}/${rows.length} stories`);
for (const { slug, error } of failures) {
console.error(slug, error.response?.data ?? error.message);
} Three details matter in the payload:
- A rich text field stores a document tree, so
markdownToStoryblokRichtext()converts the Markdown body first. - A datetime field expects the format
YYYY-MM-DD HH:mm. The CSV carries dates without a time, so the script appends00:00. content.componentnames the content type, which Storyblok requires in every story content object.
Three limits are worth knowing before you run this on a full export:
- A row with an empty
published_atfails, because the datetime field needs a date. - Requests run one at a time. For a large import, send them concurrently within the client’s rate limit.
- The script holds the file and every parsed row in memory, which suits import of a few thousand rows. Larger files may need a streaming parser.
Run the import
Run the script with the --env-file flag so Node.js loads your credentials:
node --env-file=.env import.mjs Open your space to review the result. The target folder should hold two stories as drafts, each one using the Blog Post content type, with its body rendered in the rich text editor. Publish them from Storyblok once you have reviewed the import.
Next steps
The script covers a one-time import. Three additions make it a tool you keep:
- Upload the images. Fetch each
image_url, upload it withclient.assets.upload()or the CLI, and swap the text field for anassetfield that holds the returned asset. - Make the import idempotent. An idempotent import produces the same result however often it runs. Match the existing stories by slug and call
client.stories.update()where a match exists, so a rerun corrects the content instead of failing on the slug. - Map your own Markdown.
markdownToStoryblokRichtext()accepts parser overrides, so custom syntax in a CSV can map to specific rich text nodes.
For the wider picture (inventory, content model, redirects, rollout, and more) refer to the CMS migration concept.



