On this page9 sections
When Deframer converts a published Framer site to a Next.js App Router project, each Framer CMS collection becomes two files: src/cms/<collection>.json with the records and src/cms/<collection>.ts with a plain schema. Detail pages are prerendered from those records with generateStaticParams, so the content ships inside your TypeScript project instead of living in a hosted CMS. Editing content becomes editing JSON, followed by a rebuild and a redeploy.
That trade-off suits many sites well, and it is worth understanding before you start editing. This guide walks through the files, then shows how to change a record, add a new item, work with rich text and images, and handle sites with several languages.
The two files per collection
A collection called "Blog" in Framer becomes src/cms/blog.json and src/cms/blog.ts. Field names are readable (title, slug, publishedDate, thumbnail) and appear in the same order as in the CMS.
Here is one record (real output from a converted template, shortened):
{
"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"
}
}The .ts file describes the field types and wraps the records in a JsonCollection from the project's runtime kit (@/deframer). Shortened, it looks like this:
import { JsonCollection } from "@/deframer";
import records from "./blog.json";
const schema = {
id: { type: "string" },
title: { type: "string" },
slug: { type: "string" },
publishedDate: { type: "date" },
thumbnail: { type: "responsiveimage" },
content: { type: "richtext" },
};
// Records live in ./blog.json; edit them there.
const Blog = {
collectionByLocaleId: {
default: new JsonCollection({ schema, records /* … */ }),
},
displayName: "Blog",
id: "blog",
};
export default Blog;Pages and components query the collection through the kit, the same way the Framer design filtered, sorted and paginated it. You rarely need to touch the .ts file; the JSON is where content changes happen. For the wider folder layout, see what you get in a converted Next.js project.

How detail pages are built
Each CMS detail page, such as /blog/:slug, becomes a route at src/app/blog/[slug]/page.tsx. Three exports matter:
// Title, description and social image of every CMS item, evaluated at conversion time.
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {
return (itemMetadata as Record<string, Metadata>)[decodeURIComponent((await params).slug)] ?? {};
}
// Every CMS item is prerendered; other URLs answer with the 404 page.
export const dynamicParams = false;
export function generateStaticParams() {
return [{ slug: "how-to-create-a-lasting-impression" } /* … */];
}generateStaticParams lists every item at conversion time, and dynamicParams = false means any slug not in that list gets the 404 page. generateMetadata reads metadata.json in the same folder, which holds each item's title, description, Open Graph and Twitter data. In Next.js 16, params is a Promise, which is why it is awaited.
This is the key point for editors: a record in the JSON alone does not create a page. The route needs to know about the slug too.
Editing an existing record
- Open
src/cms/blog.jsonand find the record by itsslugortitle. - Change the field values. Keep the types the schema expects: dates as ISO strings (
"2025-02-28T00:00:00.000Z"), images as objects, rich text as a JSON tree (below). - If you changed the title or summary that appears in search results and social cards, update the matching entry in
src/app/blog/[slug]/metadata.jsonas well. It was evaluated at conversion time and doesn't read the JSON record. - Run
npm run devand check both the list page and the detail page. - Commit, rebuild and redeploy.
Changing a slug changes a URL. If the old URL was indexed or linked, add a redirect for it; the SEO guide for moving off Framer covers redirects in next.config.ts.
Adding a new item
Adding an item touches four places. Follow them in order.
- Add the record. Copy an existing record in
blog.json, give it a new uniqueid(any string not already used in the file) and a newslug, then fill in the fields. Some collections have fields that hold another item'sid, such as a "next post" link; check whether your neighbors need updating. - Add the slug to
generateStaticParamsinsrc/app/blog/[slug]/page.tsx:
export function generateStaticParams() {
return [
{ slug: "how-to-create-a-lasting-impression" },
// …
{ slug: "my-new-post" },
];
}- Add its metadata to
src/app/blog/[slug]/metadata.json, keyed by slug, following the shape of the existing entries:
"my-new-post": {
"title": "My New Post",
"openGraph": { "type": "website", "title": "My New Post", "description": "…", "images": ["…"], "url": "/blog/my-new-post" },
"twitter": { "card": "summary_large_image", "title": "My New Post", "description": "…", "images": ["…"] },
"alternates": { "canonical": "/blog/my-new-post" }
} A top-level "description" is also valid here if you want a specific summary for search results.
- Add the URL to
public/sitemap.xml. The sitemap is a copy of the one the site published, so it doesn't update itself.
Then run npm run build. A slug missing from step 2 shows up as a 404, which makes the mistake easy to spot. Removing an item is the same list in reverse.
If you'd rather derive generateStaticParams from the JSON instead of listing slugs by hand, that is a reasonable refactor. Keep the output the same and test the build.
Rich text
Rich text fields become a readable JSON tree that the kit renders. A paragraph with bold text looks like this:
[
{ "type": "h6", "children": ["Understanding Branding in Business"] },
{ "type": "p", "children": ["Branding is more than just a logo."] },
{ "type": "p", "children": [{ "type": "strong", "children": ["Recognition"] }] }
]Links point at paths, and components embedded in rich text are referenced by name. The collection's .ts file maps those names to components, for example a YouTube embed:
const YouTube = lazy(() => import("@/vendor/you-tube"), "Youtube");
const richTextComponents = { YouTube };For long edits, it is often easiest to write the text elsewhere and convert it to this tree with a small script you add, rather than hand-typing nested arrays.
Images
Image fields hold src, srcSet, pixelWidth, pixelHeight and alt. By default the URLs point at Framer's CDN (framerusercontent.com). If you converted with "Include assets locally" on, they point at files in public/images/ instead.
For a new image you supply yourself:
- Add the file to
public/images/, for examplepublic/images/my-new-post.jpg. - Set
srcto/images/my-new-post.jpg, andpixelWidthandpixelHeightto the file's real dimensions so layouts reserve the right space. - Write a meaningful
alt. - For
srcSet, list the sizes you actually have. With one file, a single entry such as"/images/my-new-post.jpg 1200w"is valid.
The trade-offs between CDN and local files are covered in Framer fonts and assets: CDN vs local.
Sites with several languages
On a multi-locale site, records that differ by language get their own file per locale, such as src/cms/blog.ja-jp.json, next to the default blog.json. A locale file exists only when its content differs. Edit each language in its own file and keep id and field names aligned across them. Per-locale titles and descriptions for detail pages live in the route's metadata.json. The multi-locale guide explains the [locale] route tree.
When JSON stops being enough
JSON in the repository works well for developers, small teams and content that changes now and then: it is versioned, reviewable in pull requests and free to host. If non-developers publish often, or you want scheduled posts and editorial review, consider moving the records into a headless CMS while keeping the same record shape, so the views barely change.
Deframer is an independent tool, not affiliated with Framer; it reads the CMS content your published site already serves and needs no access to your Framer editor. More on what Deframer reads and keeps is in the FAQ.
FAQ
Does the converted site still read from Framer's CMS?
No. The records are copied into src/cms/*.json at conversion time, and the project needs nothing from Framer to run. Images may still load from Framer's CDN unless you included assets locally.
Why does my new CMS item show a 404?
Its slug is missing from generateStaticParams. Detail routes use dynamicParams = false, so only listed slugs are prerendered; add the slug and its metadata.json entry, then rebuild.
Can I add a new field to a collection?
Yes. Add the field to every record in the JSON and to the schema in the collection's .ts file, then use it in the view or component that renders the item. Keep the type consistent with existing fields of that kind.
Do I need to redeploy after editing content?
Yes. The JSON is part of the build, so content changes ship with your next deployment, just like code changes.
