Feature blog image

Contentlayer, MDX and the vercel edge function size limit

5 min read

I've recently tried to implement social media cards with @vercel/og. The implementation uses an edge function and during development everything went well, until I deployed it to Vercel. On Vercel the build ended with the following error:

error

Provided Edge Function is too large

Too large? I've used only the @vercel/og and the contentlayer/generated package. And how large is actually too large? A quick look at the vercel documentation revealed:

The maximum size for an Edge Function is 1 MB, including all the code that is bundled in the function.

1mb is not very much, but it should be enough for the two imports.

Analysis

So I installed @next/bundle-analyzer to analyze the generated bundle and found out what breaks the limit.

pnpm
yarn
npm
Copy

pnpm add -D @next/bundle-analyzer

After installing the analyzer we can configure it in the next config:

next.config.mjs
Copy

import NextBundleAnalyzer from "@next/bundle-analyzer";
import { withContentlayer } from "next-contentlayer";
/** @type {import('next').NextConfig} */
const nextConfig = {
webpack: (config) => {
// suppress warnings of webpack
// https://github.com/contentlayerdev/contentlayer/issues/313
config.infrastructureLogging = {
level: "error",
};
return config;
},
};
const withBundleAnalyzer = NextBundleAnalyzer({
enabled: process.env.ANALYZE === "true",
});
export default withContentlayer(withBundleAnalyzer(nextConfig));

If we start our build with the environment variable ANALYZE=true, the output of the bundle analyzer will be opened in a browser window. I was pretty much surprised, because the analyzer showed me that the module contentlayer/generated is 960k in size! After a quick look at the generated file at .contentlayer/generated/Post/_index.json, it became clear that the body of the post needed all the memory. Especially the code blocks with Code Hike need a lot of memory.

Solutions

Now that we know what caused the problem, we need a solution.

Load posts dynamically

My first idea was to use fetch instead of import to get the posts:

pages/api/og/posts/[slug].tsx
Copy

import { type Post } from "contentlayer/generated";
// res.json() results in a parse error,
// res.text() does also not work.
// So we use res.blob() / text() and JSON.parse().
const allPosts = fetch(new URL(`../../../../.contentlayer/generated/Post/_index.json`, import.meta.url))
.then((res) => res.blob())
.then((blob) => blob.text())
.then((text) => JSON.parse(text) as Post[]);

As the comments show, there were a few stumbling blocks, but on the whole the implementation was straightforward. So I deployed my results to Vercel, but unfortunately it showed the same error:

error

Provided Edge Function is too large

This is strange, because our bundle should be much smaller now. So I' installed the vercel cli to see the generated deployment locally.

pnpm
yarn
npm
Copy

pnpm add --global vercel

After the installation we can generate the deployment bundle by calling vercel build. The generated output shows that the bundle size is now 390K, but there is a assets directory which contains the _index.json of contentlayer.

  • .vercel/output/functions/api/og/posts/[slug].func/
    • assets
      • _index.df90b64bb8d3d072.json
        (962.24 kB)
    • index.js
      (390.22 kB)
    • index.js.map
      (551.10 kB)

It looks like the assets directory is also counted and that still exceeds the limit. So we need an other solution.

Parameters as part of the url

My second thought was to add the required parameters for the edge function to the url. So we have a url like the following:


/api/og/posts?summary=Next%20app%20directory%20and%20100%25%20height&description=...

The maximum url length is 14KiB which should be enough for our requirements. But with such a url it is easy to manipulate the parameters and to generate a social media card with our layout but with different content. To avoid such a manipulation we have to sign or encrypt the parameters. There is an example on Vercel for encrypting parameters. This should work, but it is quite an effort and the urls are still looking awful. Can we do better?

Generate json without body

A third idea came to my mind. We can generate a json, which contains all fields of the posts, but without the body.

scripts/posts-withoutbody.mjs
Copy

import { allPosts } from "../.contentlayer/generated/index.mjs";
import { mkdir, writeFile } from "fs/promises";
const createJson = () => {
return allPosts.map((post) => {
const { body, ...content } = post;
return content;
});
};
(async () => {
console.log(`create posts json without body for ${allPosts.length} paths`);
const json = createJson();
await mkdir("./.scripts/Post", {
recursive: true,
});
await writeFile("./.scripts/Post/withoutbody.json", JSON.stringify(json, null, 2), {
encoding: "utf-8",
});
})();

The script above loads the posts from the contentlayer directory, removes the body and writes the new file to ./.scripts/Post/withoutbody.json. The newly generated file is only 3.9K large.

Now we have to integrate the script into the build process of our project.

package.json
Copy

"scripts": {
"dev": "next dev",
"build": "contentlayer build && node scripts/posts-withoutbody.mjs && next build",
"start": "next start",
"lint": "next lint"
}

First we need to call the contentlayer build, so that we are able to import it in our script afterwards. Then we can run our script and finally we can run the next build.

After that build integration we can import the generated json:

pages/api/og/[slug].tsx
Copy

import allPosts from ".scripts/Post/withoutbody.json";

To ensure that we are not accidentally adding the generated file to our repository, we need to add the .scripts directory to .gitignore.


/node_modules
.contentlayer
.scripts

Now with this solution established, the build also succeeds at Vercel.

success
Build Completed in /vercel/output [44s]
Deployed outputs in 8s
Build completed. Populating build cache...
Uploading build cache [84.14 MB]...
Build cache uploaded: 1.756s
Done with "."

But this solution is only a workaround and there should be a better way backed in contentlayer itself. For this reason I also opened a ticket contentlayerdev/contentlayer#339.

Posted in: vercel, edge, mdx, contentlayer