JoyConf 2026 is back. Content Confidence. Human Connection. Save your spot!

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:

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.

posts.csv
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.mjs
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 appends 00:00.
  • content.component names 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_at fails, 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 with client.assets.upload() or the CLI, and swap the text field for an asset field 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.

Author

Manuel Schröder

Manuel transitioned from an academic background in International Relations to a career in web development, specializing in frontend engineering. Nowadays, he leads the documentation team at Storyblok, blending his technical expertise with his love for writing, teaching, and helping others understand complex concepts.