On this page8 sections
A Framer site converted with Deframer keeps each CMS collection as a JSON file (src/cms/blog.json) next to a small schema module (src/cms/blog.ts) in a Next.js App Router project written in TypeScript. To let editors work in a headless CMS such as Sanity, Contentful, Payload or Strapi, keep that record shape and change only where the records come from. The lowest-risk approach is a build-time sync: a script fetches entries from the CMS, writes the same JSON before next build, and a CMS webhook triggers a redeploy. Fetching in server components with revalidation is possible too, but it is a larger refactor of the generated views.
The CMS names here are examples, not recommendations; the pattern is the same for any CMS with an HTTP API.
Start from the record shape you already have
The converted pages don't know where records come from. They read them through the collection module, which builds a collection from the imported JSON and a plain schema. From a real converted project:
const schema = {
id: { type: "string" },
title: { type: "string" },
slug: { type: "string" },
publishedDate: { type: "date" },
category: { type: "string" },
thumbnail: { type: "responsiveimage" },
content1: { type: "richtext" },
nextItemId: { type: "string" },
// …
};And a record in blog.json:
{
"id": "cImcpXX5N",
"title": "How to Create a Lasting Impression",
"slug": "how-to-create-a-lasting-impression",
"publishedDate": "2025-02-28T00:00:00.000Z",
"category": "Branding & Identity",
"thumbnail": { "src": "https://framerusercontent.com/images/….jpg", "srcSet": "… 512w, … 810w", "pixelWidth": 810, "pixelHeight": 790, "alt": "Blog Image" }
}That shape is your contract. If the JSON keeps the same field names and value types, the views keep working without changes. Notice fields like nextItemId: records can refer to each other by id, so keep IDs stable or map them consistently. For more on the JSON format itself, see Framer CMS to JSON in Next.js.
Choose an approach
| Build-time sync | Fetch in server components |
|---|---|---|
Changes to generated views | None | Views receive records as props |
When content updates | After a rebuild and redeploy | On revalidation, without a full rebuild |
New items | Rebuild generates the new pages | Need |
Effort | Small: one script and two edits | Larger: touches routes, views and the collection module |
Most sites should start with the build-time sync. You can move to request-time fetching later if editors need faster updates than a rebuild allows.
Approach 1: build-time sync

1. Model the content in your CMS
Create a content type that mirrors the schema. Map the types:
Schema type | Typical CMS field |
|---|---|
| Short text |
| Date or datetime |
| Image or asset |
| Rich text |
Keep the slug field and, if records reference each other, a field for the original id.
2. Import the existing records
Write a one-off import script that reads src/cms/blog.json and creates entries through your CMS's management or import API. Check the CMS's docs for the exact endpoint and authentication. Import into a draft or staging environment first and compare entry counts with the JSON.
3. Add a sync script
Add a new file, scripts/sync-cms.ts, that fetches published entries and writes them in the record shape:
// scripts/sync-cms.ts (new file)
import { writeFile } from "node:fs/promises";
type ResponsiveImage = { src: string; srcSet?: string; pixelWidth: number; pixelHeight: number; alt?: string };
type BlogRecord = {
id: string;
title: string;
slug: string;
publishedDate: string;
category: string;
thumbnail: ResponsiveImage;
content1: unknown[];
};
async function fetchEntries(): Promise<any[]> {
const res = await fetch(process.env.CMS_BLOG_URL!, {
headers: { Authorization: `Bearer ${process.env.CMS_TOKEN}` },
});
if (!res.ok) throw new Error(`CMS responded with ${res.status}`);
return res.json();
}
function toRecord(entry: any): BlogRecord {
return {
id: entry.legacyId ?? entry.id,
title: entry.title,
slug: entry.slug,
publishedDate: new Date(entry.publishedDate).toISOString(),
category: entry.category,
thumbnail: toImage(entry.thumbnail),
content1: toRichText(entry.body),
};
}
async function main() {
const records = (await fetchEntries()).map(toRecord);
await writeFile("src/cms/blog.json", JSON.stringify(records, null, 2) + "\n");
console.log(`Wrote ${records.length} blog records`);
}
main().catch((error) => {
console.error(error);
process.exit(1);
});toImage and toRichText are yours to write for your CMS; both are covered below. The entry field names (legacyId, body) are placeholders for whatever your content type uses. Put CMS_BLOG_URL and CMS_TOKEN in .env.local locally and in your host's environment settings.
4. Run it before every build
Add a prebuild script, which npm runs automatically before build, and install a TypeScript runner such as tsx as a dev dependency:
{
"scripts": {
"dev": "next dev",
"prebuild": "tsx scripts/sync-cms.ts",
"build": "next build",
"start": "next start"
}
}If your host runs a custom build command, make sure it still calls npm run build.
5. Let new items get pages
Converted detail routes list their slugs in generateStaticParams, and per-item metadata comes from a metadata.json beside the route, both evaluated at conversion time. Derive both from the records instead, so a new entry gets a page and metadata on the next build:
// src/app/blog/[slug]/page.tsx (edited)
import type { Metadata } from "next";
import records from "@/cms/blog.json";
export const dynamicParams = false;
export function generateStaticParams() {
return records.map((record) => ({ slug: record.slug }));
}
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {
const slug = decodeURIComponent((await params).slug);
const record = records.find((r) => r.slug === slug);
if (!record) return {};
const url = `/blog/${record.slug}`;
return {
title: record.title,
openGraph: { type: "website", title: record.title, images: [record.thumbnail.src], url },
twitter: { card: "summary_large_image", title: record.title, images: [record.thumbnail.src] },
alternates: { canonical: url },
};
}Copy the title format and description from the existing metadata.json (a site-name suffix, for example) so pages don't change in search results. Once all metadata comes from the CMS, you can delete that file.
6. Rebuild when editors publish
Most hosts let you create a deploy hook URL, and most headless CMSs can call a webhook on publish. Connect the two, and every publish triggers a rebuild that runs the sync. Check both providers' docs for setup details; the deployment guide covers the hosts themselves.
Approach 2: fetch in server components
If rebuilds are too slow for your editors, fetch at request time. The generated collection module is a client module that imports the JSON directly, so this approach means restructuring: the route (a server component) fetches the entry with fetch(url, { next: { revalidate: 300 } }) and passes it to the view as a prop, and the view reads from that prop instead of the collection. For new slugs without a rebuild, set dynamicParams to true and handle missing entries with notFound(). For instant updates, add a route handler that checks a secret and calls revalidatePath from next/cache when the CMS webhook fires. Caching behavior in Next.js 16 depends on your configuration, so read the caching section of the Next.js docs before relying on it.
Rich text
Converted rich text is a readable JSON tree that the kit renders:
[
{ "type": "h6", "children": ["Understanding Branding in Business"] },
{ "type": "p", "children": [{ "type": "strong", "children": ["Recognition"] }] }
]Each CMS stores rich text in its own format (Sanity's Portable Text and Contentful's rich text JSON are two examples). Write toRichText to walk your CMS's tree and output nodes of this shape: block types like p and h2, inline marks like strong, and links as paths. Look at your existing records for every node type your content uses, and test with your longest article.
Images
Image fields hold src, srcSet, pixelWidth, pixelHeight and alt. Most CMSs return a URL plus dimensions; many also accept width parameters on the URL, which you can use to build srcSet entries. Always fill in pixelWidth and pixelHeight, since layouts use them for sizing.
Locales
On multi-locale sites, records differ per locale in files such as blog.ja-jp.json, which exist only when that locale's content differs. Have the sync script write one file per locale in the same way. The post on multi-locale Framer sites explains the locale structure.
For other questions about the converted project, see the FAQ.
FAQ
Do I have to change the generated page components?
Not with the build-time sync. As long as the JSON keeps the same field names and value types, the views keep working; only the detail route's generateStaticParams and generateMetadata change.
Which headless CMS works best with a converted site?
Any CMS with an HTTP API works with this pattern. Choose based on your editors' needs, hosting preference and budget, and check each vendor's current pricing.
What happens to existing URLs?
They stay the same as long as slugs don't change. Import slugs exactly as they are in the JSON, and add a redirect in next.config.ts if you ever rename one.
Can editors preview drafts?
Not with the basic build-time sync, which only fetches published entries. Draft previews need request-time fetching and your CMS's preview features, which is part of the larger refactor.
