Next.js with .NET Aspire
Introduction
Have you checked out the new tech from Microsoft? Their demos look great with the new .NET Aspire, but they only showcase .NET frontends. In my line of work, we often use Next.js to build client applications and .NET for more complex backends.
The Problem
There are no production-ready guides or articles available as of early August 2024 for integrating a Next.js application into the .NET Aspire stack. So, how can we do this? What do we need to do, and what pitfalls should we watch out for?
What is .NET Aspire?
.NET Aspire is a modern framework developed by Microsoft that enables developers to build distributed applications with ease. It focuses on simplifying the development process for scalable, cloud-native microservices and applications. With built-in support for various technologies, including Node.js and Docker, .NET Aspire is designed to integrate seamlessly into existing workflows, making it an ideal choice for developers looking to leverage the power of .NET in combination with other popular tech stacks.
Project setup
Since I don’t have a project set up for this example, we will start from scratch. We'll do this inside a mono-repo for now, as I haven't had time to explore how to handle this with multiple repositories.
Let’s get started! Open up your terminal and cd
into your favorite sandbox directory.
# let's start by creating a folder for our project, and go into it
mkdir aspire-with-nextjs
cd aspire-with-nextjs
git init
mkdir src
cd src
yarn create next-app
# current version: 14.2.5
# naming it "frontend", with TypeScript, ESLint, TailwindCSS, "src" directory, App Router and default import alias
To prepare our Next.js application for containerization with Docker, we need to configure it to create a standalone
output. We'll modify next.config.mjs
and create two files: Dockerfile
and .dockerignore
. These files are taken from the with-docker
example from Vercel’s GitHub repo.
// next.config.mjs
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "standalone",
};
export default nextConfig;
# Dockerfile
FROM node:18-alpine AS base
# Install dependencies only when needed
FROM base AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Install dependencies based on the preferred package manager
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./
RUN \
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
elif [ -f package-lock.json ]; then npm ci; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
else echo "Lockfile not found." && exit 1; \
fi
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Next.js collects completely anonymous telemetry data about general usage.
# Learn more here: https://nextjs.org/telemetry
# Uncomment the following line in case you want to disable telemetry during the build.
# ENV NEXT_TELEMETRY_DISABLED 1
RUN \
if [ -f yarn.lock ]; then yarn run build; \
elif [ -f package-lock.json ]; then npm run build; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
else echo "Lockfile not found." && exit 1; \
fi
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
ENV NODE_ENV production
# Uncomment the following line in case you want to disable telemetry during runtime.
# ENV NEXT_TELEMETRY_DISABLED 1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
# Set the correct permission for prerender cache
RUN mkdir .next
RUN chown nextjs:nodejs .next
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
# server.js is created by next build from the standalone output
# https://nextjs.org/docs/pages/api-reference/next-config-js/output
CMD HOSTNAME="0.0.0.0" node server.js
# .dockerignore
Dockerfile
.dockerignore
node_modules
npm-debug.log
README.md
.next
!.next/static
!.next/standalone
.git
With these changes we have made our Next.js application ready to be built and deployed with Docker.
.NET Aspire setup
First of we need to create a new .NET Aspire project with CLI:
# create a new .NET Aspire project, named Kongebra (name this whatever you want)
dotnet new aspire -n Kongebra
# I have a preference for a project structure, so I will do this to make my brain go smile
# so I move everything into src, and remove the created folder made by "dotnet new aspire"
mv Kongebra/* src
rm -r Kongebra
Now we got our .NET Aspire project ready for implementing Next.js into it. So let’s add some dependecies we need for this project:
Aspire.Hosting.NodeJs
So now let’s try to run the .NET Aspire application by entering this command:
dotnet run --project src/Kongebra.AppHost/Kongebra.AppHost.csproj
This will give us the .NET Aspire Dashboard at a local endpoint (take a look in your console to get the correct URL for this), for me it was https://localhost:17122
. And this should give us a result looking like this:
Run Next.js development server
So let’s start by diving into Program.cs
in our AppHost
project. We want to add npm run dev
from our frontend folder, so that we can have the development-server running
var builder = DistributedApplication.CreateBuilder(args);
// Add a NPM app, named "frontend", at "../frontend", with "npm run dev"
builder.AddNpmApp("frontend", "../frontend", "dev")
// allow Aspire to control the port via env variable PORT
.WithHttpEndpoint(env: "PORT")
// give the app an extenral endpoint
.WithExternalHttpEndpoints();
builder.Build().Run();
This will spin up our Next.js project inside our Aspire app with the Next.js development server. This setup allows for local development with hot module reloading and live updates.
However, this is not efficient for running our frontend Next.js application in production. We need to figure out how to run it with Docker, as we have prepared our Next.js app earlier.
Aspire + Next.js + Docker = ❤️
Let's see if we can make Aspire run our Next.js application as a Docker container. To do this, we need to use something other than AddNpmApp
and look at AddDockerfile
so we can run the app from our Dockerfile.
We'll need to change the code a bit (but still keep what we added previously to work efficiently in our local environment) and switch to using Docker.
// Add a Dockerfile app, named "frontend", at "../frontend"
builder.AddDockerfile("frontend", "../frontend")
// allow Aspire to control the port via env variable PORT and target port 3000
.WithHttpEndpoint(env: "PORT", targetPort: 3000)
// give the app an extenral endpoint
.WithExternalHttpEndpoints();
Setting up is just as easy as for the NPM app, but we removed the startup command “dev” and added a targetPort
, which is the internal port running in our container.
With this, we have a relatively production-ready build of our Next.js application. It runs very fast and is easy to deploy since we are now using Docker for building and running the application.
Let’s make it ready for azd
If we want to deploy this to Azure with azd
, we need to support both local development and a production-ready build for our Next.js application. Let's see what we need to do to make it work for both.
For this example, I will use a simple condition in our Program.cs
, but for your production code, I recommend making an extension method to handle this more cleanly.
var builder = DistributedApplication.CreateBuilder(args);
if (builder.ExecutionContext.IsPublishMode) {
// Add a Dockerfile app, named "frontend", at "../frontend"
builder.AddDockerfile("frontend", "../frontend")
// allow Aspire to control the port via env variable PORT and target port 3000
.WithHttpEndpoint(env: "PORT", targetPort: 3000)
// give the app an extenral endpoint
.WithExternalHttpEndpoints();
} else {
// Add a NPM app, named "frontend", at "../frontend", with "npm run dev"
builder.AddNpmApp("frontend", "../frontend", "dev")
// allow Aspire to control the port via env variable PORT
.WithHttpEndpoint(env: "PORT")
// give the app an extenral endpoint
.WithExternalHttpEndpoints();
}
builder.Build().Run();
Here we are taking advantage of the IsPublishMode
from the ExecutionContext
that is a context related to .NET Aspire, and we can take a look at the documentation what it says:
The ExecutionContext property provides access key information about the context in which the distributed application is running. The most important properties that the DistributedApplicationExecutionContext provides is the IsPublishMode and IsRunMode properties. Developers building .NET Aspire based applications may whish to change the application model depending on whether they are running locally, or whether they are publishing to the cloud.
And when we run our Aspire app locally with dotnet run
we can see in our dashboard that the Next.js application is running via npm run dev
.
Conclusion
Integrating Next.js with .NET Aspire allows us to leverage the strengths of both frameworks, creating robust and scalable applications. Through this guide, we've explored setting up both a Next.js and a .NET Aspire project, preparing the Next.js application for Docker, and configuring the .NET Aspire project to run the Next.js application in both development and production environments. By using Docker and considering deployment with Azure's azd
, we ensure that our application is ready for both local development and production deployment. This approach not only streamlines the development process but also enhances the efficiency and reliability of the application in various environments.
Pitfalls
Think of Next.js as a Node.js project
Well, it is, but in the context of .NET Aspire, we need to treat it as an NPM project, using AddNpmApp
instead of AddNodeApp
. The AddNodeApp
looks for a file to start with the node
command, not npm run dev
. We could have done this without Docker and run .next/standalone/server.js
for production code, but then we would need to add code for building the project and move the public and static files into the standalone folder.
Starting too big
This article addresses a relatively small task: spinning up a single Next.js application. When I first attempted this, I tried to do it with a larger system involving multiple components and other apps and projects. Starting small makes it easier to isolate issues and use the divide and conquer approach.
Future steps
Take a look at how you can use azd
to deploy the application to Azure, and see how easy you can go from local development to having the application live in Azure.
Also take a look at adding a backend, or adding several frontend applications in to the same project.
References
- https://nextjs.org/docs/app/building-your-application/deploying#docker-image
- https://learn.microsoft.com/en-us/dotnet/aspire/get-started/build-aspire-apps-with-nodejs
- https://learn.microsoft.com/en-us/dotnet/aspire/app-host/withdockerfile
- https://learn.microsoft.com/en-us/dotnet/aspire/deployment/azure/aca-deployment
Here is a repository containing the code for this article: https://github.com/kongebra/aspire-with-nextjs