.NET Aspire & Next.js: The Dev Experience You Were Missing
Tired of trying to get hot-reloading to work in Docker? I know I am. With all the hype around .NET Aspire, I figured it was time to see if I could get my favorite “hobby” stack working with .NET Aspire. Spoiler alert: it was easier than I thought!
Lately, I really enjoy combining a Next.js website with a .NET Web API. This setup lets me tap into the extensive React and Next.js ecosystem to quickly build great websites with all the power of .NET on the backend
Now, let's get both of them running in Aspire to make this stack even better!
Streamline Local Development with .NET Aspire
Next.js has been the modern web developments golden standard for years, and with the recent release of React 19, that momentum isn’t slowing down. But if you’ve ever tried running a Next.js frontend alongside a .NET API in local development, you already know the pain:
- Managing Dockerfiles and docker-compose just to get a basic setup working
- Fighting hot-reloading issues with .NET inside Docker
- Constantly tweaking volume mounts and dotnet watch run hoping for a smooth experience
Let’s be real—traditional containerized development sucks for quick iteration. Enter .NET Aspire, Microsoft’s local development orchestration tool. With Aspire, you get:
- Seamless orchestration - No need for deviating Dockerfiles for local development
- Built-in service discovery
- Support for hot-reloading - Changes in your API reflect instantly, no container restarts required
- Sane local development - Run everything together without the usual containerization headaches
Honestly, setting up Aspire is so easy, I now add it to all my new projects. Even if I don’t need it right away, I know it’ll save me time down the line.
Setting Up .NET Aspire for API and Frontend Development
Let’s get the boilerplate out of the way. We need a solution that includes a .NET Aspire Host and ServiceDefaults, a .NET API, and a Next.js website. You can run the following commands to set up a basic project.
_14# Create the the required projects_14md DotnetAspireNext | cd_14dotnet new aspire_14md DotnetAspireNext.Api | cd_14dotnet new webapi_14cd .._14_14# Wire everything together_14dotnet sln add .\DotnetAspireNext.Api\_14dotnet add .\DotnetAspireNext.Api\ reference .\DotnetAspireNext.ServiceDefaults\_14dotnet add .\DotnetAspireNext.AppHost\ reference .\DotnetAspireNext.Api\_14_14#create Next.js project_14npx create-next-app@latest
Running a .NET API with Aspire is pretty easy, for an API without any dependencies we can simply add this line to our DotnetAspireNext.AppHost/Program.cs
:
_10var api = builder.AddProject<DotnetAspireNext_Api>("api");
Adding Next.js to .NET Aspire
Aspire allows us to orchestrate Node.js apps using the Aspire.Hosting.NodeJS
package. This package provides two ways to integrate Node projects: AddNodeApp
and AddNpmApp
. Even though Next.js is technically a Node app, we need to use AddNpmApp
because Next.js apps run with npm run dev
instead of node somefile.js
.
If you prefer using Yarn or PNPM, you can use CommunityToolkit.Aspire.Hosting.NodeJS.Extensions
.
_10dotnet add DotnetAspireNext.AppHost package Aspire.Hosting.NodeJS_10# or when using Yarn or PNPM_10dotnet add DotnetAspireNext.AppHost package CommunityToolkit.Aspire.Hosting.NodeJS.Extensions
Now we can configure Aspire to run our Next.js project like so:
_10var api = builder.AddProject<DotnetAspireNext_Api>("api");_10_10var frontend = builder_10 .AddNpmApp("frontend", "../frontend", "dev")_10 .WithNpmPackageInstallation()_10 .WaitFor(api)_10 .WithReference(api)_10 .WithHttpEndpoint(env: "PORT") // Make sure Next.js uses the port assigned by Aspire_10 .WithExternalHttpEndpoints();
Running the Aspire project will quickly show us that both our API and Next app are running and accessible:
Fetching Data from Your .NET API in Next.js
There are several ways to access our API from our Next.js project.
The simplest approach is to export a variable containing the API endpoint or create a fetch
/axios
wrapper with the endpoint hardcoded. However, you’d then need a way to update this value when deploying to a server.
A better approach is to use environment variables. Fortunately, Aspire automatically adds an environment variable to our frontend project when we include .WithReference(api)
. If we check the Aspire Dashboard, we can see an environment variable called services__api__http__0
containing the API endpoint.
Using Next.js as a proxy
While environment variables should work, you might run into CORS issues when calling the API from a client component. One way to solve this is by using Next.js as a proxy.
We can achieve this using Next.js rewrites:
_16// next.config.js_16const API_URL = process.env["services__api__http__0"];_16_16/** @type {import('next').NextConfig} */_16const nextConfig = {_16 async rewrites() {_16 return [_16 {_16 source: "/api/:path*",_16 destination: `${API_URL}/:path*`,_16 },_16 ];_16 },_16};_16_16module.exports = nextConfig;
The downside of this approach is that rewrites only work on the client side. Calling fetch('/api/someEndpoint')
works fine in a client component but will fail in a server component.
The best way to handle this is to export a constant variable that uses a different URL depending on where the code is executed:
_10// constant.js_10export const APIEndpoint =_10 typeof window === "undefined"_10 ? process.env["services__api__http__0"]_10 : "/api";
Accessing .NET Aspire Service Endpoints Dynamically
Aspire works some magic behind the scenes to ensure each service has a single predictable endpoint, regardless of how many replicas or services are running.
This 'magic' boils down to Aspire placing proxies before each service you register. It is the proxy that listens on the port defined in launchSettings.json
or .WithEndpoint()
. Meanwhile, the actual app runs on a different endpoint managed by Aspire. This allows multiple instances of an application to run while keeping a single predictable endpoint.
While this setup is useful, it can cause issues if an application assumes its internal address matches its external address.
Endpoints are assigned after calling builder.Build().Run();
. However, .WithEnvironment()
has an overload that accepts a callback, which runs after Aspire assigns all endpoints. This allows us to use GetEndpoint()
on a service resource to set environment variables dynamically. Normally, calling GetEndpoint()
too early would throw an exception since the endpoint wouldn’t exist until the Aspire stack is built.
_10var frontend = builder_10 .AddNpmApp("frontend", "../frontend", "dev")_10 // ... more configuration_10 ;_10_10frontend.WithEnvironment("SOME_ENV_VAR", ()=>frontend.GetEndpoint("http").Url);
Wrapping Up
.NET Aspire makes it surprisingly easy to orchestrate a local development environment. Instead of dealing with complex Docker setups or unreliable hot-reloading, you get a streamlined workflow where everything just works.
If you're already working with .NET, this setup is worth exploring. It makes local development a lot more enjoyable and efficient. Adding additional dependencies, like Redis or PostgreSQL, is just as easy as adding a new project.
Of course, no tool is perfect. .NET Aspire is still evolving, and depending on your project’s needs, you may run into edge cases. But if you’re looking for a cleaner, more efficient way to develop locally, this approach is certainly worth trying.
I’ll be diving deeper into .NET Aspire in future articles—covering more advanced scenarios, potential pitfalls, and how it fits into real-world workflows. So if you're experimenting with it yourself, keep an eye out for what’s next.
What to read next:
I really hope you enjoyed this article. If you did, you might want to check out some of these articles I've written on similar topics.
- Read Three ways to structure .NET Minimal APIs— 9 minutes readThree ways to structure .NET Minimal APIs
- Read Improve Signalr and React Performance User Experience— 4 minutes readImprove Signalr and React Performance User Experience
- Read Sync React with SignalR Events— 7 minutes readSync React with SignalR Events