Skip to main content

Deploy Full Next.js Site on Cloudflare: Are we there?

· 14 min read

Next.js and Cloudflare Pages are so cool. Can we have a full Next.js website running on Cloudflare Pages?

With the release of Next.js 12.2, we can choose which runtime we use to render pages or serve API on the Next.js server.

What does that mean? Well, back in the day, the Next.js server could only run in a Node.js runtime, which means it's not compatible with Cloudflare Worker, or, almost all serverless edge computing platforms, which only support the V8 runtime. You can read more about the difference between them here. To save you 10 minutes of reading if you are not interested in the tech detail -- it's much faster and cheaper to run on V8 runtime. Node.js runtime demands so many resources that deploying them on edge is something only giant cloud providers can afford. To the best of my knowledge, the only company providing this is Amazon.

You can, of course, deploy your shining Next.js website on Vercel with zero configuration, but that costs way more than Cloudflare. Same as most cloud providers, Vercel will charge you based on the bandwidth consumed, and sit tight, it's $40/100GB. That actually, is not that expensive considering netlify (which also supports deploying Next.js with zero configuration) will ask for $55. On Cloudflare, bandwidth is always free.

Isn't Deploying Next.js on Cloudflare Pages Already Supported?

Well, yes and no.

Next.js has many ways to generate web pages. You can pre-render all web pages when building the website. That way your website is fully static and you can deploy it on Cloudflare Pages and it's free (unless you are updating your website madly).

However, if you have so many web pages that building them takes hours, you can use incremental static regeneration (ISR). In this mode, the server will generate the web page the first time any user accesses it, and cache the page for future requests. Or, if you want to serve a dynamic page, but you want it to be rendered on the server for SEO reasons, while still keeping the SPA experience for users, you can use server-side rendering (SSR).

You can't do ISR or SSR with the static generation, you need somewhere to run the code to generate the web pages when users access them. And Cloudflare Pages does provide dynamic hosting through Cloudflare Worker, where you can run javascript or wasm on the v8 runtime, but not the Node.js runtime. So deploying a Next.js website with the full feature was not possible (to some extent).

Serverless Support of Next.js

Since Next.js can use V8 runtime now, we can use Cloudflare Pages to deploy the dynamic Next.js website, right?

In theory, yes.

Cloudflare Worker follows the serverless pattern, where there is no long-running process handling requests. A new instance (may be cached) is initiated for every request.

The Next.js framework itself only supports one dynamic serving pattern officially, by running next start to start the Next.js server with a long-running process.

serverless target and standalone output

There used to be a serverless target that can produce one standalone javascript file for one page so we may use them with a light wrapper to generate the page, but that has been deprecated in favor of the new output file tracing feature.

With this new feature, Next.js will track the dependencies of each page. Combined with output: 'standalone' config, Next.js will copy all needed dependencies to the output folder with a minimal server.js file that can be used instead of next start, so it's smaller to deploy. But that's a full Next.js server and that server is not compatible with V8 runtime, it will scan the disk to find the configurations and the source files, then build the route map.

The edge runtime

Then what about the new edge runtime? What will we get by enabling edge runtime?

To better understand this, let's first try to figure out what "Next.js running in edge runtime" really means.

After reading a lot of source code, I finally find out that it means Next.js server will execute the API Routes (which is mostly the code you write) in edge runtime to handle all requests. Next.js expects there is always a Next.js server running, it is just a matter of in what runtime Next.js server executes your code.

So how can we run the server on Cloudflare Worker?

Well, we cannot, at least not directly.

If you take a look at how vercel or netlify further processes the build artifacts of next build to make them work on their servers, you will see it's not a trivia task.

Let's try to find out what needs to be done.

** Everything below assumes you have a certain experience of working with Next.js and Cloudflare Pages Function**

Note I don't think Next.js provides any detail about the content of the output in the documents, so most of the things below should be considered as the internal implementation of version (12.2.3) of Next.js. (There is a small issue with the latest 12.2.5)

How Next.js build artifacts are organized

Find source code at

%[https://github.com/zhuhaow/nextjs-cloudflare-page-demo]

Let's create a demo website that contains a combination of SSG, ISR, SSR pages, and an edge API Route first.

Note the page will also be compiled to an edge API Route eventually.

pages
├── _app.tsx
├── api
│ └── hello.ts
├── dynamic
│ └── [page].tsx
├── index.tsx
└── static
└── [page].tsx

We have a fully static page index.

A static page with fallback so we can test ISR.

import { GetStaticPaths, GetStaticProps } from "next"

interface Props {
page: string
}

export const getStaticProps: GetStaticProps<Props> = async context => {
const page = context?.params?.page as string

return {
props: {
page,
},
}
}

export const getStaticPaths: GetStaticPaths = async () => {
return {
paths: ["SSG"].map(page => ({ params: { page } })),
fallback: true,
}
}

const Page = ({ page }: Props) => {
return (
<div>
<h1>Page {page}</h1>
</div>
)
}

export default Page

export const config = {
runtime: "experimental-edge",
}

And a simple dynamic page

import { GetServerSideProps } from "next"

export const getServerSideProps: GetServerSideProps = async ({ query }) => {
const page = query.page as string

return { props: { page } }
}

const Page = ({ page }: { page: string }) => {
return (
<div>
<h1>Page {page}</h1>
</div>
)
}

export default Page

export const config = {
runtime: "experimental-edge",
}

And the API

import type { NextApiRequest } from "next"

export default function handler(_req: NextApiRequest): Response {
return new Response("Hello World")
}

export const config = {
runtime: "experimental-edge",
}

Let's take a look at what next build generates.

.next
├── BUILD_ID
├── build-manifest.json
├── export-marker.json
├── images-manifest.json
├── next-server.js.nft.json
├── package.json
├── pages
│ ├── api
│ │ └── hello.js.nft.json
│ ├── dynamic
│ │ └── [page].js.nft.json
│ └── static
│ └── [page].js.nft.json
├── prerender-manifest.json
├── react-loadable-manifest.json
├── required-server-files.json
├── routes-manifest.json
├── server
│ ├── chunks
│ │ ├── 675.js
│ │ ├── 783.js
│ │ └── font-manifest.json
│ ├── edge-chunks
│ │ ├── 566.js
│ │ └── 566.js.map
│ ├── edge-runtime-webpack.js
│ ├── edge-runtime-webpack.js.map
│ ├── font-manifest.json
│ ├── middleware-build-manifest.js
│ ├── middleware-manifest.json
│ ├── middleware-react-loadable-manifest.js
│ ├── pages
│ │ ├── 404.html
│ │ ├── 500.html
│ │ ├── _app.js
│ │ ├── _app.js.nft.json
│ │ ├── _document.js
│ │ ├── _document.js.nft.json
│ │ ├── _error.js
│ │ ├── _error.js.nft.json
│ │ ├── api
│ │ │ ├── hello.js
│ │ │ └── hello.js.map
│ │ ├── dynamic
│ │ │ ├── [page].js
│ │ │ └── [page].js.map
│ │ ├── index.html
│ │ ├── index.js.nft.json
│ │ └── static
│ │ ├── [page].js
│ │ └── [page].js.map
│ ├── pages-manifest.json
│ └── webpack-runtime.js
└── trace

I omit the content in cache and static folders.

There seem to be a lot of files, but their purpose is quite clear.

.next/static folder (omitted in the tree) contains the assets that should be served as-is, they are contents generated by preprocessors, bundlers, etc, not by the Next.js server. There is no dynamic content there.

In .next/server folder, we can find everything generated by the server, including the ones pre-generated when running next build such as index.html, 404.html.

And there are .nft.json files (output file tracing) tracking the dependencies of each javascript file as we mentioned before.

The js files under .next/server/pages matter the most, but how much do these scripts do compared to a full Next.js server?

The answer is not very exciting.

The js files in .next/server/pages are all API Routes where each just handles the request to one HTTP endpoint, that's all.

If we start a Next.js server (next start), it will load these files and the json files alongside them containing the route mappings and server configurations. But the server is not something runnable on the V8 runtime.

While it's technically possible to have a headless Next.js server running at the edge in a serverless mode, Next.js is probably not planning to do that.

Run API Routes on Cloudflare Pages

Let's see if we can get the API Routes up and running on Cloudflare Pages without a Next.js server.

If we open .next/server/pages/static/[page].js, we will see a very short file (< 150 lines) generated by webpack which suggests this file won't work standalone. There is some good reason why dependencies are not bundled within the script that we will discuss later.

Next.js compiler uses webpack to do chunk splitting and for now, we can use the corresponding nft file to find what files should be required to get the edge functions up and running.

I'm not sure if this is the right way to use webpack chunks but I can't find any resource talking about this. So I just figure this out by looking at the source code of the chunks.

Let's create cf/functions/api/hello.js with

global._ENTRIES = global._ENTRIES || {}
global.process = global.process || { env: { NODE_DEBUG: false } }

require("../../../.next/server/pages/api/hello")
require("../../../.next/server/edge-runtime-webpack")

export async function onRequest(context) {
return (
await global._ENTRIES["middleware_pages/api/hello"].default({
request: context.request,
})
).response
}

So what is happening here?

First I create the file under cs/functions so later we can use wrangler to start a dev Cloudflare Pages server in cf locally for testing.

Next, I define _ENTRIES into which webpack will load the modules. I also polyfilled process since it's not available in the V8 runtime but required (see here for what Next.js does).

Then I load the chunk module api/hello and finally load edge-runtime-webpack to load all the modules into _ENTRIES.

In this way, if we have a context to handle the request, we can just load the part we want to handle within this context with no duplicate code in each entry. This is very important for Cloudflare as Pages would bundle everything into one file and if the entry files are already bundled then there will be a lot of duplications in the final worker bundle.

After some tests and trials to find out the exported function's signature, we then have a wrapper that simply forwards the Cloudflare request to Next.js API Route.

Now if we run wrangler pages dev . in cf/, and head to http://localhost:8788/api/hello, we will see the lovely "Hello World".

Doing the same for the pages, however, won't work for now.

global._ENTRIES = global._ENTRIES || {}
global.process = global.process || { env: { NODE_DEBUG: false } }

require("../../../.next/server/edge-chunks/553")
require("../../../.next/server/pages/dynamic/[page]")
require("../../../.next/server/edge-runtime-webpack")

export async function onRequest(context) {
return (
await global._ENTRIES["middleware_pages/dynamic/[page]"].default({
request: context.request,
})
).response
}

You will see an error due to an issue with stream API of Cloudflare Pages (see the discussion here). But I hope it will work after it's fixed on the Cloudflare side.

Running Full Next.js Site on Cloudflare

So, assuming the stream API issue has been fixed, if we automate this process and map all edge functions to Cloudflare Pages functions, can we run a Next.js website on Cloudflare now?

Maybe. It depends on what Next.js feature you are using.

Keep in mind that we don't have a Next.js server, we only have API Routes handlers plus whatever Cloudflare provides with us (such as the automatic routing based on the file hierarchy so api/hello is handled by functions/api/hello.js).

To fully support all Next.js features, we need to use a Cloudflare Pages middleware to do everything a server should do.

I'll list something I can think of that is not working out of the box.

Worker size

This is probably the most immediate problem I can see.

When I start the dev server locally there is one line of log that says:

[pages:inf] Worker reloaded! (0.94MiB)

I'm not sure if there could be any further optimization done to shrink the size, but the limit of worker size is 1MB (Cloudflare Pages would combine all functions into one worker script), and we haven't imported anything yet.

Confirmed with Cloudflare that the size shown in the log is not minified. I tried to minify the bundle and the size of Next.js dependencies should be ~460KB.

Image

This may or may not be an issue based on how you plan to use it.

If you just disable image optimization, then you are good to go.

But if you want to have image optimization work out-of-the-box, Cloudflare Image Resizing requires a Pro plan. There is no quota for the free plan. (And I dare you to find the price detail of it in 10 minutes on Cloudflare's website.)

A little extra code is also required to verify if the requested image is allowed.

Request object

We need to extend the request object to NextRequest. Cloudflare already provides all information.

Routing

The static files should be served as is and have higher routing priority by default, so there should be nothing to worry about.

The dynamic pages and API Routes are all placed in the right place so it should just work.

However, there are a few gotchas.

Page transition

When transitioning to a new page with next/link or next/router, the page won't refresh, instead, a json file containing all the necessary data needed to render that page is requested. E.g., in our example, when we transitioning to dynamic/123 from index, the browser actually makes a request to /_next/data/[BUILD_ID]/dynamic/123.json?page=123. So we need to make sure we are mapping that path to be handled by the right handler.

Basepath and locales

This type of information should be passed to the handler by filling the NextRequest object. We need to make sure we map the request to the correct handler by stripping these parts from the URL.

Caching

Cloudflare Workers supports cache but not implicitly as what Next.js server does, we can do them in the wrapper, and it should be quite straightforward. But I'm not sure if stale-while-revalidate is possible.

Middleware

Next.js middleware needs to be handled by Cloudflare Pages middleware. I haven't investigated this yet. But it should be possible.

next.config.js

Most of the behavior of Next.js server is configured by next.config.js.

Headers

We have to implement this in middleware since it has some feature not supported by Cloudflare Pages config.

Rewrites and Redirects

We have to use middleware. Next.js redirects and rewrites are more powerful than the configuration provided by Cloudflare.

Trailing slash

Middleware, again.

Conclusion

In theory, it's possible to host a full Next.js website on Cloudflare Pages now.

Given the wrapper for API Routes is quite simple (probably we should wrap them in the middleware instead of letting wrangler do this), what is still missing is a Pages middleware that acts exactly the same as a Next.js server but can work in the V8 runtime.

Currently, there are at least 4 frameworks that support SSR on Pages, but they are all provided by the framework owners instead of Cloudflare. Given the owner of Next.js is vercel and Next.js doesn't even support deploy on vercel itself, it's very unlikely Next.js will ever officially support deploy on Cloudflare (or any other platforms).

I don't think Cloudflare has started working on it yet. But the great news is ItsWendell mentioned in discord that there is some progress.

Hope it will be ready soon.