Richard Kovacs

Bring Your Own Next.js

Richard Kovacs
Richard Kovacs
8 min read
Listen

I am a huge fan of Next.js, but I follow a strict personal policy of only using it for the front end — no server actions, no edge functions, nothing else outside TSX. Of course, I had a very good reason to do so: vendor lock-in.

Vendor lock-in is when you are using a specific product that only works on the provider's platform. In my case, the product is Next.js, and the platform is Vercel. When you are using a product like this, your fate is totally in the hands of the provider. They can increase your costs, deprecate features, have regular outages, etc. And if you are dependent on them, taking your stuff somewhere else is extremely painful, if not impossible.

While the frontend part is easy to move elsewhere, I cannot say the same about lambda functions. They work the best on Vercel as Vercel has the architecture ready for it. I have only found one viable alternative so far in the form of AWS Amplify, which takes a quarter of a lifetime to set up correctly compared to deploying to Vercel with a single click.

But even if AWS caught up to Vercel in simplicity, they would be one step behind at each new Next.js release.

What Happened?

My view on the above totally changed recently when I stumbled upon a specific part in the docs about self-hosting Next.js. It isn't brand new; they probably have had it there for ages, but it only caught my attention last week.

Apparently, you have the option to host Next.js yourself. You just need a Node environment or Docker. Of course, the docs mention that you can achieve the best performance if you host your site on Vercel, but I am totally okay with that. Honestly, the best thing Vercel could have done to Next.js is to enable anyone to take their app wherever they prefer, thus eliminating vendor lock-in. An awesome move! Now, let's see how it works in practice.

Hosting Next.js Locally with Node.js

I know the development mode is also running locally on Node, but I am speaking about the production version. Hosting the app locally this way is pretty straightforward. You are actually only two commands away from it: next build and next start.

The first command will build your entire application and put it in the .next folder at the root of your project. If you are interested, the app will be located in the .next/server folder. You will find everything here: frontend, lambda functions, edge functions, and everything your app contains.

The second command will start and continuously serve the app from here.

And that's it. You now have a full-stack Next.js application running on your machine in production mode. You can take this to any provider you prefer. Just build the app and make sure to start it. Only Node.js is required.

However, this is not the only option the docs provide. You have another alternative.

Containerized Next.js

If you are a fan of microservices or a regular user of a provider that supports Docker containers, a containerized Next.js application might better suit your needs. The docs contain steps for this setup as well, although it only works out of the box if you clone their example on GitHub. If you want to dockerize an existing application, follow the guide below.

I chose the Docker Compose example from the official Next.js repository. I prefer Compose because settings are much more readable than in a single-line docker command.

This blog was written in Next.js, and I currently host it on Vercel. The blog posts are written in Markdown, and when you open one of the posts like this one, it is server-side rendered into the HTML you are looking at right now, so this blog was a good candidate for containerization as it contains multiple client-side rendered pages but also an SSR page.

Standalone Output

One of the first steps is to change your output to standalone in your next.config.js.

module.exports = {
  output: 'standalone',
}

This setting ensures that only the necessary files and dependencies are copied to the output folder, making the result much smaller.

Dockerfile

The next step is to obtain a Dockerfile. I used the Dockerfile from the Docker Compose example. You can find plenty of other examples in the repository, such as plain Docker option, Docker with multi-stage and multi-environment setups, etc.

The Dockerfile was almost perfect, but it needed some tweaking to work with this blog's source. The builder step copies the source and some config files into the image, but since I am using Tailwind, I had to add two additional lines below line 22:

COPY postcss.config.js .
COPY tailwind.config.js .

I also had to add another line to this list to copy my markdown pages.

COPY _posts ./_posts

If your application contains other config files and/or folders, make sure that they are copied as well.

I also use shadcn/ui components, but the components.json was not required since that file is only needed when generating a new component from the CLI. The production version does not use it, as the file is not needed during build time.

I also have some environment variables in the repository. I had to add two more lines below line 29 for each required at build time. I had to do the same for each run time variable below line 66. For example, if your app is using EXTERNAL_URL, you have to add the following two lines in the corresponding places:

ARG EXTERNAL_URL
ENV EXTERNAL_URL=${EXTERNAL_URL}

With these modifications, the Dockerfile was ready.

Docker Compose

I started with the Docker Compose present in the example, but it also needed some minor changes. Since I put both the Dockerfile and the docker-compose.yml in the root of the repo, I had to change the context of the build setting to ./. I also had to change the dockerfile parameter to simply Dockerfile.

In the next step I had to define the environment variables. Going with the previous example, if you rely on EXTERNAL_URL, your build step needs the following args:

EXTERNAL_URL: ${EXTERNAL_URL}

Here is how the final compose file looks like:

version: "3"

services:
  next-app:
    container_name: next-app
    build:
      context: ./
      dockerfile: Dockerfile
      args:
        EXTERNAL_URL: ${EXTERNAL_URL}
    restart: always
    ports:
      - 3000:3000
    networks:
      - my_network

networks:
  my_network:
    external: true

How to Start a Containerized Next.js?

If you have everything set up correctly, you can start your containerized application by running docker compose build followed by docker compose up.

⚠️ Remember that this is an optimized production build. Auto restart on file changes won't work, as the goal of this build is to be deployed to a provider.

Caveats

Here are some pitfalls I faced when porting my app to Docker. Pay extra attention to these as fixing them required the most time in my case.

Environment Variables

Make sure that you define your environment variables in every location. Build-time variables should be present in the builder step, and run-time variables should be added to the runner step. Additionally, add them to the compose file. Since the production build is optimized, it won't tell you that this was the problem; it will just throw an error.

Sharp

You will probably see an error saying the sharp package is required in production builds. Next.js uses this package under the hood when you define an Image from the next/image package. However, simply installing it in some cases won't solve the problem as there is an ongoing version mismatch problem.

One option is to define the following variable:

ENV NEXT_SHARP_PATH=/app/node_modules/sharp

If the variable above does not help, ensure your next package is updated to the latest version. Updating next to version 14.2.3 fixed the error, without needing the environment variable. The sharp version that worked was 0.33.3.

Missing Folders, Configs

Double-check that every required folder and config file is copied to the images. As I am using Tailwind, I needed both postcss.config.js and tailwind.config.js. I also had to add my _posts folder. Check which folders and configs your app relies on and copy each.

Standalone Build

Finally, if you are using Docker, ensure the standalone output mode is correctly set in your next.config.js.

Wrapping It Up

Vendor lock-in is a sneaky move, and I'm glad I was wrong about Vercel. Offering the option to take your app to another provider builds trust, which is essential to keeping your users long-term. At least, that's my view on the topic.

With that being said, I am still hosting this blog with them, and I don't plan to change this anytime soon. However, there are other private projects where I need the self-hosted option, which is now fairly easy based on the steps above.

If you still have questions left or want to add something to this post, let me know on any of the social channels I am active on. You can find them down below.