I attempted to migrate a production NextJS application from the heavy Node.js standalone runtime to a lightweight architecture. The objective was to utilize NextJS output: 'export' to generate static assets, which would then be served by a custom, lightweight Go server bundled within the same Docker image.
The application was a complex, internationalized website supporting eight distinct locales. A primary requirement was to maintain a universal build. This meant the Docker image had to be agnostic of environment variables to allow deployment across various stages without rebuilding. The primary goal was to get rid of the RAM consumption of the heavy NextJS runtime. NodeJS/Bun runtime consumed ~ 200-450MB of RAM for the standalone mode and ~140MB for the static export.
To achieve the universal build, environment variables were stripped from the build process. Instead of baking API endpoints into the static frontend, static paths such as /api/graphql were utilized. The responsibility for handling these requests was assigned to the bundled Go server, which acted as a proxy to forward traffic to the appropriate backend services defined by runtime environment variables.
Image optimization was similarly decoupled from the NextJS runtime. A custom image loader was implemented to rewrite image requests to a dedicated CDN path, ensuring the static export remained independent of image processing logic.
A significant obstacle arose regarding the handling of dynamic routes, such as blog posts accessed via a slug. NextJS Static Exports require generateStaticParams to define all paths at build time. To avoid the inefficiency of pre-rendering every possible blog post, the architecture was modified to utilize query parameters instead of dynamic route segments. This allowed the serving of a single, generic static HTML file for all content requests, which the client-side application would then populate with specific data.
The project was ultimately abandoned due to an irresolvable conflict regarding Search Engine Optimization, specifically the requirements for the Canonical URL metadata.
While most metadata can remain static without a significant impact, the canonical tag requires an absolute URL, including the full domain, to function correctly. In NextJS's app directory, metadata must be defined through generateMetadata or the static metadata export, both of which execute exclusively at build time for static exports. There is no built-in mechanism to defer this to runtime without a Node.js server. Consequently, in a universal static export, the domain name cannot be known at build time.
To rectify this within the proposed architecture, the Go server would be required to parse the static HTML and dynamically replace the domain or domain-like placeholder value within the canonical tag string at runtime. This approach was rejected as it effectively requires implementing Go-based templating over a static export. Such manipulation is not elegant and introduces a significant risk of hydration issues, where the client-side React application mismatches with the modified server-sent HTML.
Unless you need SEO ranking, a lightweight runtime with a multi-env Docker image is achievable. Otherwise, it can be achieved only via very hacky solutions, such as replacing the placeholder ENV variables inside static .html and .js via a custom templating engine.
For the NextJS apps where you need dynamic environment variables like BACKEND_URL, API_ENDPOINT, or a custom rewrite, you can use a lightweight custom Golang-based server. It’s easy, and it works (~13 MB of RAM usage). There are a lot of workarounds that can be implemented for the range of features.
For the website where SEO is not important, there are a lot of servers that can host your static export. For example, in one of my projects, I use a Rust-based static-web-server (~8 MB of RAM usage).
Here's what the attempt looked like in numbers:
If I were to achieve the desired result at all costs, the update would have required roughly 4× more inserted lines and changes across ~100 files.
