Move a Framer blog to Markdown files in Next.js

Turn a Framer CMS blog into one Markdown file per post in a Next.js project, with a conversion script, a renderer and the same URLs, so writing is a Git commit.

Published
Reading time
6 min
Deframer cover: “Every post, a Markdown file.” Beside it, a list of blog.json, Markdown and Blog route with Markdown selected
On this page8 sections

To move a Framer blog to Markdown, first get the posts out as data, then write each one to a .md file with frontmatter, then point the blog's detail route at those files instead of the JSON. With Deframer, the first step is done for you: converting the site writes the blog collection to src/cms/<collection>.json, with rich text as a JSON tree that a 60-line script can turn into Markdown. Keep the slugs exactly as they are and the URLs don't change.

Markdown suits developer-run blogs: posts live in Git, review happens in pull requests, and any editor works. It suits non-technical writers less well; if that's your team, look at adding a headless CMS instead. Deframer is an independent tool and is not affiliated with, endorsed or sponsored by Framer.

The plan

  1. Convert the Framer site and download the project.
  2. Run a script that writes content/posts/<slug>.md from the blog's JSON.
  3. Change the blog detail route to read Markdown.
  4. Change the blog index to list Markdown files.
  5. Check every old URL, then delete the JSON once nothing reads it.

Steps 3 and 4 touch generated files, so commit the untouched conversion to Git first. Maintaining a converted site with Git explains why that baseline matters.

Step 1: get the posts as JSON

Convert the published site in Deframer to Next.js, the default. If you're leaving Framer entirely, pick "Assets in the project" so post images are downloaded into public/ instead of staying on Framer's CDN. Converting is free; downloading uses 1 credit after you verify the site is yours (or the owner's permission), or nothing with the lifetime deal.

In the project, the blog collection is two files. If the collection is called "Blog":

  • src/cms/blog.json: the records.
  • src/cms/blog.ts: their typed schema.

Field names follow your CMS, so check yours. A typical post record has title, slug, a date, a thumbnail image and a rich-text content field. The CMS to JSON guide explains the shapes in detail.

Step 2: write the Markdown files

Rich text in the JSON is a tree of elements, each with a type and children, where text is plain strings:

json
[
  { "type": "h2", "children": ["Why it matters"] },
  { "type": "p", "children": ["Read ", { "type": "a", "href": "/blog/other-post", "children": ["this"] }, " first."] }
]

This script walks that tree and writes one file per post. Adjust the field names at the top to match your collection:

ts
// scripts/blog-to-markdown.ts
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";

type Node = string | { type: string; href?: string; src?: string; alt?: string; name?: string; children?: Node[] };
const posts = JSON.parse(readFileSync("src/cms/blog.json", "utf8")) as Record<string, any>[];

const inline = (nodes: Node[] = []): string => nodes.map((node) => {
  if (typeof node === "string") return node;
  const text = inline(node.children);
  switch (node.type) {
    case "strong": return `**${text}**`;
    case "em": return `*${text}*`;
    case "code": return `\`${text}\``;
    case "a": return `[${text}](${node.href ?? ""})`;
    case "br": return "  \n";
    default: return text;
  }
}).join("");

const block = (node: Node): string => {
  if (typeof node === "string") return node;
  const heading = /^h([1-6])$/.exec(node.type);
  if (heading) return `${"#".repeat(Number(heading[1]))} ${inline(node.children)}`;
  switch (node.type) {
    case "p": return inline(node.children);
    case "blockquote": return `> ${inline(node.children)}`;
    case "ul": return (node.children ?? []).map((li) => `- ${inline(typeof li === "string" ? [li] : li.children)}`).join("\n");
    case "ol": return (node.children ?? []).map((li, i) => `${i + 1}. ${inline(typeof li === "string" ? [li] : li.children)}`).join("\n");
    case "pre": return `\`\`\`\n${inline(node.children)}\n\`\`\``;
    case "img": return "!" + `[${node.alt ?? ""}](${node.src ?? ""})`; // a Markdown image
    case "component": return `<!-- TODO: embedded component ${node.name} -->`;
    default: return inline(node.children);
  }
};

mkdirSync("content/posts", { recursive: true });
for (const post of posts) {
  const front = [
    "---",
    `title: ${JSON.stringify(post.title)}`,
    `date: ${String(post.publishedDate ?? "").slice(0, 10)}`,
    post.thumbnail?.src ? `image: ${post.thumbnail.src}` : "",
    "---",
  ].filter(Boolean).join("\n");
  const body = (post.content as Node[] ?? []).map(block).join("\n\n");
  writeFileSync(`content/posts/${post.slug}.md`, `${front}\n\n${body}\n`);
}
console.log(`wrote ${posts.length} posts`);

Run it with node scripts/blog-to-markdown.ts (Node.js 22.18 or later runs TypeScript directly; otherwise use npx tsx). Then open a few files next to the live posts and compare. Expect to fix:

  • Embedded components (a YouTube embed, a custom callout). The script leaves a TODO comment; decide whether each becomes a link, an HTML embed or an MDX component.
  • Nested lists and tables, which the simple script flattens.
  • Image fields inside rich text, whose exact shape you should confirm in your JSON.

The script is a starting point you own, not a finished tool; keep it in the repo so you can rerun it after fixes.

Step 3: render Markdown in the detail route

Install a Markdown parser and a frontmatter reader. Many work; this example uses gray-matter and marked:

sh
npm install gray-matter marked

Add a small loader:

ts
// src/lib/posts.ts
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import matter from "gray-matter";
import { marked } from "marked";

const dir = join(process.cwd(), "content/posts");

export function allPosts() {
  return readdirSync(dir).filter((f) => f.endsWith(".md")).map((f) => {
    const { data, content } = matter(readFileSync(join(dir, f), "utf8"));
    return { slug: f.slice(0, -3), title: String(data.title), date: String(data.date), image: data.image as string | undefined, content };
  }).sort((a, b) => b.date.localeCompare(a.date));
}

export function postHtml(slug: string) {
  const post = allPosts().find((p) => p.slug === slug);
  return post ? { ...post, html: marked.parse(post.content) as string } : undefined;
}

Then, in the blog detail route (for example src/app/blog/[slug]/page.tsx), list the slugs in generateStaticParams from allPosts(), and render the post's HTML inside the existing post layout. The cleanest way to keep the design is to reuse the generated view's layout components and swap only the rich-text area for:

tsx
<article className={styles.body} dangerouslySetInnerHTML={{ __html: post.html }} />

Because the HTML comes from your own repository, rendering it this way is safe. If outside contributors can open pull requests with posts, sanitize it or use a renderer that builds React elements.

Style the article with a CSS module. The project's text styles are classes in src/styles, so headings and paragraphs can reuse the same typography as before.

Metadata: the route's metadata.json holds each post's title, description and social image as published. Either keep it and add entries for new posts, or move those fields into frontmatter and build generateMetadata from it. The second keeps everything for one post in one file.

Step 4: the blog index

The blog index page reads the collection to list posts. Point it at allPosts() and map the fields it shows (title, date, thumbnail, excerpt). If the list is a Framer design component with its own props, pass the same props from the Markdown data so the cards look identical.

Step 5: check every URL

Slugs come from file names, which the script took from the old records, so /blog/<slug> stays the same. Confirm it:

sh
npm run build && npm start
for f in content/posts/*.md; do
  slug=$(basename "$f" .md)
  code=$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:3000/blog/$slug")
  [ "$code" = "200" ] || echo "$code $slug"
done

Then update the sitemap so new posts are listed as you write them; the sitemap guide shows a generated sitemap.ts. When everything passes, delete src/cms/blog.json and its schema if nothing else imports them (grep -rn "cms/blog" src).

Should you use MDX instead?

MDX lets a post include React components, which is handy for the embedded components the script couldn't express. It adds a build step and some complexity. Start with Markdown; move individual posts to MDX when they need it.

FAQ

Can I export a Framer blog to Markdown directly?

Not from Framer itself. Get the posts as data first, either from Framer's CMS export options or by converting the site with Deframer, then turn the data into Markdown with a script like the one above.

Will my blog URLs change?

No, if you keep the slugs. The script names each file after the post's slug, and the route serves /blog/<slug> as before.

What happens to images in posts?

Their URLs are kept. They point at Framer's CDN unless you converted with "Assets in the project", in which case they're paths to files in public/.

Can non-developers still write posts?

They can edit Markdown on GitHub's web editor, but it isn't a comfortable CMS. If writers aren't developers, connect a headless CMS instead and keep Markdown out of their way.

Does this work for TanStack Start, Astro or Vite projects?

Yes. The script and the Markdown files are the same; only the route that renders a post differs per framework. Astro also has its own content collections for Markdown, which you can use instead of the loader above.