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:
- Create a new (empty) folder.
- Init a new git repository there via
git init
. - Link the newly created git repository to Heroku.
- Copy over your built app to the new git.
- 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:
- Updating the
package.json
to have abuild
script and astart
script. - Run
heroku login
, followed by (optionallyheroku create
to create a new app, then)heroku git:remote -a <heroku-app-name>
to link the repo to Heroku. - 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 🥁
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:
- NextJS built a bunch of files, not only the standalone server. What is this needed for?
- NextJS built a
server.js
file, but it’s nested inside aapps/next-app/
folder - why is it nested? - 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 rootpackage.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 likeError: listen EINVAL: invalid argument <IPv6 addr>
, then you have to start the server viaHOSTNAME=0.0.0.0 node server.js
.
Looks like everything is working, but when you go to the site, you’ll see this:
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 thestandalone/public
andstandalone/.next/static
folders manually, after whichserver.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:
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
# 🚀 🚀 🚀 🚀 🚀 🚀 🚀