Deploying to Heroku is meant to be easy. But with the lack of configuration comes a lack of flexibility. In this blog post I want to show you, what we learned by deploying a Nx + standalone NextJS app to Heroku. And how you can apply our learnings to your project as well!

If you prefer code over text, I’ve provided a full shell script in the Recap section.

Take everything I say with a grain of salt. I’m no Heroku expert and there might be other, simpler ways to achieve the same result. However, to the best of my knowledge - and research capabilities -, what I describe below is the easiest and most versatile solution yet.

TL;DR: In your build pipeline (like GitHub action) execute these steps:

  1. Create a new (empty) folder.
  2. Init a new git repository there via git init.
  3. Link the newly created git repository to Heroku.
  4. Copy over your built app to the new git.
  5. Push to Heroku.

Preface: Heroku Doesn’t Play Nicely with Monorepos

The default (and recommended) deployment mechanism is to push your git repository to Heroku. This push then triggers a build + deploy pipeline on Heroku based on scripts or files in the root of the application. For NodeJS based apps, this is the package.json file.

However, monorepos contain more than one application. Having the config for building + deploying of one app in the root package.json would prevent us from deploying any other app.

Additionally, the root package.json usually contains a union of all dependencies needed for all applications. Heroku therefore might install dependencies, that are not needed for the app that’s currently being deployed - like Angular for a server application. This increases the time for the deployment and the size of the final bundle.

Besides, having build and start scripts in the package.json might tempt new developers to execute those scripts to, well, build and start the app(s). But they may not even work, because they are specifically meant for the Heroku environment. In monorepos you usually want to use tools like Nx or Turborepo to build/start/manage the apps instead. For new devs, the scripts in the package.json might therefore just be misleading and confusing, as they are not meant for devs.

Finally, many projects have sophisticated CI/CD pipelines using GitHub Actions, for instance. Those build steps might be hard to (also) manage for the Heroku build. CI pipelines usually provide a rich feature set, that is not exposed by Heroku’s build system. For example:

  • declaring steps and dependencies between them
  • running on specific workers (like Windows, Mac, self-hosted, etc.)
  • getting separate reports for each steps
  • having a library or recipes ready for you to re-use
  • documentation and community support for a wide range of topics
  • caching
  • parallelism
  • etc. etc.

To be fair, there are community maintained buildpacks, that might ease some of the pain points.There is the monorepo buildpack, that allows you to set an APP_BASE path, which Heroku will cd into before building/starting your app. But the issues above still largely prevail, i.e. polluting your monorepo with scripts, that are not directly managed by Nx/Turborepo/etc., managing two different pipelines, and losing many of the benefits you get when (already) using proper CI pipelines.

For those reasons, I propose to apply the traditional architecture of CI pipelines: build on a worker an artifact, then push the artefact to a host for deployment.

Let’s Build an App

I’m a big fan of show, don’t tell, so we’ll create a NextJS app together. Because this will be a simulation for a larger project, we go a bit overboard with the tooling and use a monorepo. To manage our monorepo, I’ll use Nx, but the tooling doesn’t really matter here. What’s important is the generalization we derive.

So let’s get going!

First, we need to bootstrap a new Nx workspace with a NextJS application:

pnpm dlx create-nx-workspace@latest --preset=next

Here is the settings I’ve used to bootstrap my example project:

 NX   Let's create a new workspace [https://nx.dev/getting-started/intro]

✔ Where would you like to create your workspace? · deploy-anything-to-heroku
✔ Application name · next-app
✔ Would you like to use the App Router (recommended)? · Yes
✔ Would you like to use the src/ directory? · Yes
✔ Test runner to use for end to end (E2E) tests · none
✔ Default stylesheet format · scss
✔ Which CI provider would you like to use? · skip
✔ Would you like remote caching to make your build faster? · skip

 NX   Creating your v20.4.2 workspace.

✔ Installing dependencies with pnpm
✔ Successfully created the workspace: deploy-anything-to-heroku.

 NX   Welcome to the Nx community! 👋

🌟 Star Nx on GitHub: https://github.com/nrwl/nx
📢 Stay up to date on X: https://x.com/nxdevtools
💬 Discuss Nx on Discord: https://go.nx.dev/community

Just quickly make sure that everything works by running

# next-app is the name of your application, yours might differ
pnpx nx dev next-app
> nx run next-app:dev

> next dev

   ▲ Next.js 15.1.7
   - Local:        http://localhost:3000
   - Network:      http://192.168.178.100:3000

 ✓ Starting...

   We detected TypeScript in your project and reconfigured your tsconfig.json file for you.
   The following suggested values were added to your tsconfig.json. These values can be changed to fit your project's needs:

        - include was updated to add '.next/types/**/*.ts'

 ✓ Ready in 1029ms
 ○ Compiling / ...
 ✓ Compiled / in 1280ms (586 modules)
 GET / 200 in 1390ms

Nice 👌

Once again, we won’t focus on the details now, as all we are interested in is how to deploy this app, not what it does. So let’s go straight to deployment.

For that, I’ll just do what the Heroku guide tells me to do. In short, that’s:

  1. Updating the package.json to have a build script and a start script.
  2. Run heroku login, followed by (optionally heroku create to create a new app, then) heroku git:remote -a <heroku-app-name> to link the repo to Heroku.
  3. Push to Heroku via git push heroku main.

Sounds neat, so I update my package.json:

{
  "scripts": {
    "build": "nx build next-app",
    "start": "nx start next-app"
  }
}

Then connect the repo to Heroku:

heroku create deploy-anything
heroku git:remote -a deploy-anything

And finally:

git push heroku main

Drum roll please 🥁

An error occurred in the application and your page could not be served. If you are the application owner, check your logs for details.

Bummer.

The first issue is, that our start script uses nx to start the next server. However, Heroku will prune all dev dependencies and nx is a definitely a dev dependency. Okay, so we have to figure out what nx start next-app does under the hood, so that we can avoid using it. The updated start script is:

{
  "scripts": {
    "build": "nx build next-app",
-    "start": "nx start next-app"
+    "start": "cd apps/next-app/ && next start"
  }
}

After another git push heroku main, however, we are still getting an error!

⨯ Failed to load next.config.js, see more info here https://nextjs.org/docs/messages/next-config-error
Error: Cannot find module '@nx/next'

Nx is just too deeply integrated into the NextJS app! We’d need to add more and more stuff into “production dependencies”, that aren’t supposed to be needed in production.

“No!”, I said to myself. “There must be a better way…”

Conquering NextJS

I already felt like making a compromise, when adding scripts to my root package.json. Having project-specific scripts in the root context breaks the isolation level of projects in the monorepo.

So I scouted and found that you can build NodeJS apps in “standalone” mode. In this mode, NextJS will compile everything down to a server.js file - including everything that next start does behind the scene. As a nice bonus, we don’t need the next CLI to run the app - something that bothers me as well, because the CLI is more a dev+build tool to me than something I want to have on my production server. But I digress.

With that in mind, I update my next.config.js file like so:

const nextConfig = {
  nx: {
    // Set this to true if you would like to use SVGR
    // See: https://github.com/gregberge/svgr
    svgr: false,
  },
+  output: 'standalone',
};

The next time you run nx build next-app, the app will now build a apps/next-app/.next/standalone folder next to a bunch of other files and folders:

ls -lh apps/next-app/.next 
total 776K
-rw-r--r--. 1 shayden shayden 1.3K Feb 13 17:29 app-build-manifest.json
-rw-r--r--. 1 shayden shayden   78 Feb 13 17:29 app-path-routes-manifest.json
-rw-r--r--. 1 shayden shayden   21 Feb 13 17:29 BUILD_ID
-rw-r--r--. 1 shayden shayden  995 Feb 13 17:29 build-manifest.json
drwxr-xr-x. 1 shayden shayden   60 Feb 13 17:29 cache
drwxr-xr-x. 1 shayden shayden   72 Feb 13 17:29 diagnostics
-rw-r--r--. 1 shayden shayden   94 Feb 13 17:29 export-marker.json
-rw-r--r--. 1 shayden shayden  515 Feb 13 17:29 images-manifest.json
-rw-r--r--. 1 shayden shayden  23K Feb 13 17:29 next-minimal-server.js.nft.json
-rw-r--r--. 1 shayden shayden 442K Feb 13 17:29 next-server.js.nft.json
-rw-r--r--. 1 shayden shayden   20 Feb 13 17:29 package.json
-rw-r--r--. 1 shayden shayden  685 Feb 13 17:29 prerender-manifest.json
-rw-r--r--. 1 shayden shayden    2 Feb 13 17:29 react-loadable-manifest.json
-rw-r--r--. 1 shayden shayden 5.8K Feb 13 17:29 required-server-files.json
-rw-r--r--. 1 shayden shayden  751 Feb 13 17:29 routes-manifest.json
drwxr-xr-x. 1 shayden shayden  666 Feb 13 17:29 server
drwxr-xr-x. 1 shayden shayden   56 Feb 13 17:29 standalone
drwxr-xr-x. 1 shayden shayden   60 Feb 13 17:29 static
-rw-r--r--. 1 shayden shayden 259K Feb 13 17:29 trace
drwxr-xr-x. 1 shayden shayden   60 Feb 13 17:29 types

Inside the standalone folder, you’ll see something like this:

tree -L 3 -a apps/next-app/.next/standalone
apps/next-app/.next/standalone
├── apps
│   └── next-app
│       ├── .next
│       └── server.js
├── node_modules
│   ├── next
│   ├── react
│   └── react-dom
└── package.json

So to recap:

  1. NextJS built a bunch of files, not only the standalone server. What is this needed for?
  2. NextJS built a server.js file, but it’s nested inside a apps/next-app/ folder - why is it nested?
  3. NextJS created it’s own node_modules folder with only a very small subset of the dependencies we initially declared. And NextJS included a copy of the root package.json inside the .next/standalone folder. Why?

To answer the first question, NextJS will emit a lot of meta data, that might accelerate future builds or help the tooling to do its job. However, for us this means weeding everything out that we don’t need. More on that later.

For the second point, NextJS must ensure, that relative imports, that it may not be able to resolve at compile-time, are still valid at runtime. If you have something like import '../../node_modules/my-lib', that must work at runtime as well.

And finally, NextJS’s build process “will use @vercel/nft to statically analyze import, require, and fs usage to determine all files that a page might load”. That’s how you end up with the smallest possible node_modules folder that you’ll need to run the app. That’s actually pretty neat. However, what’s not so neat, is that this is not reflected in the accompanying package.json file. Vercel’s idea is that you can just push the whole folder to the cloud. However, from my own experience, Heroku is not happy when you check the node_modules folder into their git. Heroku will reject pushes that are larger than ~200MB.

So now that we know what NextJS did, now we have to answer “What do we have to do now?”

To answer the question, let’s try and run the server.js file.

cd apps/next-app/.next/standalone/apps/next-app
node server.js
   ▲ Next.js 15.1.7
   - Local:        http://localhost:3000
   - Network:      http://0.0.0.0:3000

 ✓ Starting...
 ✓ Ready in 32ms
If you get an error like Error: listen EINVAL: invalid argument <IPv6 addr>, then you have to start the server via HOSTNAME=0.0.0.0 node server.js.

Looks like everything is working, but when you go to the site, you’ll see this:

The browser&rsquo;s network tab showing a lot of 404 errors.

All the files that are “static”, like the CSS or images, don’t load. The reason for this is that NextJS will not include static files in the standalone output.

Here’s what the documentation has to say about this:

This minimal server does not copy the public or .next/static folders by default as these should ideally be handled by a CDN instead, although these folders can be copied to the standalone/public and standalone/.next/static folders manually, after which server.js file will serve these automatically.

But there is a catch! Remember that our server.js is in a sub-folder of the standalone folder. Confusingly, we therefore have to ignore the documentation and copy the static folder to standalone/apps/next-app/.next/standalone/static and the public folder to standalone/apps/next-app/public. Not standalone/.next/static and standalone/public.

cp -r apps/next-app/.next/static   apps/next-app/.next/standalone/apps/next-app/.next/static
cp -r apps/next-app/public         apps/next-app/.next/standalone/apps/next-app/public

Let’s try to run the server again via

cd apps/next-app/.next/standalone/apps/next-app
node server.js

And voila, our assets are now resolved:

The browser&rsquo;s network tab showing that now all static files could be loaded.

Deploying to NextJS With Some Git Magic

Just so that we don’t get lost, what do we currently have:

  • We have started with a monorepo that contains a NextJS app
  • Pushing the whole monorepo to Heroku is not really a solution
  • We have figured out how to build the NextJS app as standalone app

The goal right now is to push only this standalone app instead of the monorepo.

For this, I’ll convert the standalone folder to a git repo to push to Heroku.

There are other solutions to push a single directory to Heroku - or any git repository for that matter. However, I found that the cleanest and possibly simplest solution is to bootstrap a completely new git repository.

The first step is to create a new folder and cd into it. It doesn’t really matter where, I’ll call it heroku-build in the project root folder. Just make sure that this folder is empty.

mkdir heroku-build
cd heroku-build

Init a new git repository:

# using "main" as explicit branch name for later reference
git init -b main

Link to Heroku:

heroku git:remote -a deploy-anything

If this is not your first deployment, make sure to sync the local repo to Heroku:

git reset --mixed heroku/main
In contrast to git pull heroku main, this command will keep the repo empty, so that we have a clean folder to copy stuff into.

If the new git repo is not in sync with Heroku, then pushing the commit at the end will be rejected, because you need to run pull first.

Now we can finally copy over the pre-built app:

cp -r ../apps/next-app/.next/standalone/. .
# If your standalone folder already contains the static and public directory,
# you can skip the following two commands
cp -r ../apps/next-app/.next/static       apps/next-app/.next/static
cp -r ../apps/next-app/public             apps/next-app/public

If you do a git status, you’ll notice that the node_modules folder, that NextJS created for us, is also included. However, we can’t push this folder, as Heroku will reject git repositories that are too large (~200MB from my experience).

To solve this, we first have to add the node_modules folder to the .gitignore:

echo "node_modules" > .gitignore

To download the dependencies again, Heroku needs a package.json (which was already included in the standalone folder) and an up-to-date and valid pnpm-lock.yml (or yarn.lock, package-lock.json, etc.):

cp ../pnpm-lock.yaml .

One last step is still missing. The package.json still contains the scripts from before. You could update the root package.json with the new scripts. But I rather recommend to remove the scripts outright. Only the Heroku deployment needs those scripts, so add them only to the heroku_build/package.json file. Luckily, we can easily do this with jq:

jq '.scripts = { "start": "node apps/next-app/server.js" }' ../package.json > package.json

A small, but noticeable optimization is to minimize the packages that need to be installed on Heroku by pruning the devDependencies from the package.json as well. You can do both updates to the package.json in one step:

jq 'del(.devDependencies) | .scripts = { "start": "node apps/next-app/server.js" }' ../package.json > package.json

However, if you do this, the pnpm-lock.yml will no longer be in sync with your package.json. To update the pnpm-lock.yml (without installing the node_modules again), you can use this command:

pnpm install --lockfile-only

For yarn and npm you have to research yourself if you need this step and how to best update the respective lock files.

Now the app is ready for deployment!

Commit everything to git:

git add .
git commit -m "My successful deployment #1"

And push:

git push heroku main

And you’re done! Congrats on deploying to Heroku 💪

Recap

In order to deploy apps (especially inside monorepos) to Heroku, it’s quite easy to just copy the built files to a new folder, init a git repo there, and push this tiny folder to Heroku.

To condense all of what I’ve written above, I’ll leave you with a Bash script, that you may copy and modify to your heart’s content. You can also find the full script with an accompanying NextJS project here: https://github.com/Simon-Hayden-Dev/deploy-anything-to-heroku

# ℹ️ This script is meant as a template. Swap out parts that you don't need or
# want to adapt to your needs.

# Exit on error
set -e

# REQUIREMENTS
# 📥 📥 📥 📥 📥 📥 📥
#   git
#   heroku
#     Logged in and
#     With created Heroku project (see "heroku create"). Update the
#      HEROKU_PROJECT config (see below) to the correct app name.
#   jq
#   pushd & popd (should be built-in into all UNIX shells)
# 📥 📥 📥 📥 📥 📥 📥


# CONFIG
# ⚙️ ⚙️ ⚙️ ⚙️ ⚙️ ⚙️ ⚙️
# Change these to what you need
HEROKU_PROJECT=deploy-anything

BUILD_FOLDER=heroku_build
# 💡 Alternatively, create a temp folder
# BUILD_FOLDER=`mktemp -d`

# In case this script is not in the project root, change this line
PROJECT_ROOT=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )

# Which files to copy is very project specific. Will be called inside the
# $BUILD_FOLDER. Use $BUILD_PATH for absolute path to $BUILD_FOLDER
copy_files () {
  STANDALONE_FOLDER="$PROJECT_ROOT/apps/next-app/.next/standalone"
  STATIC_FOLDER="$PROJECT_ROOT/apps/next-app/.next/static"
  PUBLIC_FOLDER="$PROJECT_ROOT/apps/next-app/public"

  echo " 📦 Copying standalone from $STANDALONE_FOLDER to $BUILD_PATH"
  cp -r "$STANDALONE_FOLDER/." "$BUILD_PATH"
  echo " 📦 Copying static from $STATIC_FOLDER to $BUILD_PATH"
  cp -r "$STATIC_FOLDER" "$BUILD_PATH/apps/next-app/.next"
  echo " 📦 Copying static from $STATIC_FOLDER to $BUILD_PATH"
  cp -r "$PUBLIC_FOLDER" "$BUILD_PATH/apps/next-app/"
}
# Same as above
setup_package_json () {
  SRC_PACKAGE_JSON="$PROJECT_ROOT/package.json"
  TARGET_PACKAGE_JSON="$BUILD_PATH/package.json"

  SRC_PNPM_LOCK="$PROJECT_ROOT/pnpm-lock.yaml"
  TARGET_PNPM_LOCK="$BUILD_PATH/pnpm-lock.yaml"

  echo " 📦 Remove devDependencies and override scripts"
  jq 'del(.devDependencies) | .scripts = { "start": "node apps/next-app/server.js" }' "$SRC_PACKAGE_JSON" > "$TARGET_PACKAGE_JSON"
  echo " 📦 Copy & repair pnpm-lock.yaml"
  cp "$SRC_PNPM_LOCK" "$TARGET_PNPM_LOCK"
  pnpm install --lockfile-only
}
# Customize the message to use for the commit in the Heroku repo.
build_commit_message () {
 echo "Deployment of commit $(cd $PROJECT_ROOT; git rev-parse HEAD)"
}
# ⚙️ ⚙️ ⚙️ ⚙️ ⚙️ ⚙️ ⚙️

# BUILDING
# 🛠️ 🛠️ 🛠️ 🛠️ 🛠️ 🛠️ 🛠️
echo "🛠️ Building app..."
echo

pnpm install
nx build next-app

echo
echo "🛠️ Building app done"
echo
# 🛠️ 🛠️ 🛠️ 🛠️ 🛠️ 🛠️ 🛠️

# PACKAGE
# 📦 📦 📦 📦 📦 📦 📦
echo "📦 Packaging the app..."
echo
echo "📦 Clearing & creating folder" "$BUILD_FOLDER"
# Make sure the build folder is completely empty before we start
rm -rf "$BUILD_FOLDER"
mkdir "$BUILD_FOLDER"

# Convert relative path to absolute path
BUILD_PATH="$(cd "$BUILD_FOLDER"; pwd)"

# cd into the BUILD_PATH, but we'll use popd later
pushd "$BUILD_PATH" > /dev/null

echo "📦 Copying files to" "$BUILD_PATH"
copy_files
echo "📦 Files copied to" "$BUILD_PATH"

echo "📦 Setup dependencies & Heroku scripts..."
setup_package_json
echo "📦 Setup dependencies & Heroku scripts done"

popd > /dev/null

echo
echo "📦 Packaging the app done"
echo
# 📦 📦 📦 📦 📦 📦 📦

# DEPLOY
# 🚀 🚀 🚀 🚀 🚀 🚀 🚀
echo "🚀 Deploying app to Heroku..."
echo

# cd into the BUILD_PATH, but we'll use popd later
pushd "$BUILD_PATH" > /dev/null

echo "🚀 Initializing a new Git repo in $BUILD_PATH"
# Adding node_modules to .gitignore
echo "node_modules" > .gitignore
# Explicitly use "main" as branch name, in case the user setting has a different
# default branch.
git init -b main
heroku git:remote -a "$HEROKU_PROJECT"

echo "🚀 Pulling changes from Heroku"
git fetch heroku
# Set the git repository to heroku/main, without changing the local files
git reset --mixed heroku/main

echo "🚀 Committing changes to Heroku"
git add .
git commit -m "$(build_commit_message)"

echo "🚀 Pushing changes to Heroku..."
git push heroku main
echo "🚀 Pushing changes to Heroku done"

popd > /dev/null

echo
echo "🚀 Deploying app to Heroku done"
echo
# 🚀 🚀 🚀 🚀 🚀 🚀 🚀