What happens to Framer CMS collections in Next.js?

Framer CMS collections in Next.js become JSON files with a schema. How to edit records, add items, and handle rich text, images and locales.

Published
Reading time
6 min
Deframer cover: “Every collection, now a JSON file.” Beside it, two cards, Framer CMS to JSON, with JSON selected
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):

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"
  }
}

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:

ts
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.

A Framer CMS collection called Blog becomes two files: src/cms/blog.json with the records, and src/cms/blog.ts with the field types, wrapped in a JsonCollection
Each collection becomes its records and a typed module that reads them.

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:

tsx
// 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

  1. Open src/cms/blog.json and find the record by its slug or title.
  2. 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).
  3. 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.json as well. It was evaluated at conversion time and doesn't read the JSON record.
  4. Run npm run dev and check both the list page and the detail page.
  5. 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.

  1. Add the record. Copy an existing record in blog.json, give it a new unique id (any string not already used in the file) and a new slug, then fill in the fields. Some collections have fields that hold another item's id, such as a "next post" link; check whether your neighbors need updating.
  2. Add the slug to generateStaticParams in src/app/blog/[slug]/page.tsx:
tsx
   export function generateStaticParams() {
     return [
       { slug: "how-to-create-a-lasting-impression" },
       // …
       { slug: "my-new-post" },
     ];
   }
  1. Add its metadata to src/app/blog/[slug]/metadata.json, keyed by slug, following the shape of the existing entries:
json
   "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.

  1. 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:

json
[
  { "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:

ts
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:

  1. Add the file to public/images/, for example public/images/my-new-post.jpg.
  2. Set src to /images/my-new-post.jpg, and pixelWidth and pixelHeight to the file's real dimensions so layouts reserve the right space.
  3. Write a meaningful alt.
  4. 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.