Key Takeaways
- Developers keep moving to Single Page Applications (SPAs), though client-side development doesn’t mean you need a SPA in every case.
- There are several ways to use SPA frameworks with ASP.NET Core, including middleware, directory-based connection, or integration into your project.
- Middleware and directory-based methods come with some constraints, such limited platform support, and deployment and dependency complexities.
- Directly integrating SPAs into your can build islands of functionality into websites.
- Choosing which method to use is often more about skill and suitability than architectural purity.
Web development has changed in the past few years, with the maturity of Angular, React, Vue, and others. We’ve moved from building web pages to building apps. We’ve also been shifting from rendering markup on the server, to more commonly rendering it directly in the browser. In most cases, this has really made browser-based usability increase. But as developers continue to transition to client-side development, many are asking if they should still be using ASP.NET.
Single Page Applications and Web Development
Single Page Applications (SPAs) aren’t a new invention. But SPAs are "eating" the web development world in many ways. For some websites, such as Facebook and Twitter, the move to SPAs has been really helpful, which is why some SPA frameworks have come directly from the same companies responsible for these sites. Developers everywhere have noticed and implemented SPAs for their own sites and especially for enterprise applications.
If you know anything about me, you know I like client-side development, but the term "SPA" gives me the chills. I prefer to think of client-side frameworks like Angular, React, and Vue as a way to augment a website. By creating a single huge SPA, we’re heading back down the road of monolithic applications from the Visual Basic 5 days.
I see these frameworks as a way to create islands of functionality, focused on what client-side code does well: user interaction. When you replace everything in the website with an SPA, you have to resort to other methods for improving search engine optimization and mimicking landing URLs to make it feel like a website. Unless you’re building the next Twitter or Facebook, I just don’t get it.
Using SPA Frameworks with ASP.NET Core
If you’re new to ASP.NET Core, you might not know that there are several ways to use SPA frameworks with it:
- Microsoft-built middleware for Angular and React.
- Use an SPA in its own directory, and build that into an ASP.NET Core Project.
- Integrate an SPA framework directly into a project.
Let’s talk about each of these.
SPA Middleware
Microsoft created a middleware solution to more easily support using SPAs that are built around their own ecosystem. The middleware takes a very hands-off approach, which just allows an SPA to sit in a subdirectory and work directly in isolation. Here’s how it works.
How to Use the Middleware Method
You can build this approach directly in the new project template in Visual Studio, as seen in Figure 1.
Figure 1
This approach works by keeping the one large SPA project (in this example Angular) in a ClientApp directory, as shown in Figure 2.
Figure 2
In ASP.NET Core 3.0, supporting SPAs works via a middleware to then point to this directory (no matter what you call it). It does this by injecting the middleware as the last step in Startup’s Configure method:
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
// ...
if (!env .IsDevelopment())
{
app.UseSpaStaticFiles();
}
app.UseRouting();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllerRoute(
name: "default",
pattern: "{controller}/{action=Index}/{id?}");
});
app.UseSpa(spa =>
{
// To learn more about options for serving an Angular SPA from ASP.NET Core,
// see https://go.microsoft.com/fwlink/?linkid=864501
spa.Options.SourcePath = "ClientApp";
if (env.IsDevelopment())
{
spa.UseAngularCliServer(npmScript: "start");
}
});
});
}
This involves two calls: UseSpaStaticFiles and UseSpa. These calls set up the ability to get calls from a generated index.html that Angular-CLI builds for you. In this case, you’ll see that during development, it will launch ‘npm start’ for you; that is part of how it launches the development build of the Angular app.
This is similar to how the middleware works for React as well. Currently, there is no support for other platforms (e.g., Vue, Backbone, etc.). The other limitation is that while there is an old version of the Spa middleware that worked in ASP.NET Core 2.0, the new version only works with ASP.NET Core 2.1 and later.
Use SPA as Subdirectory of ASP.NET Core
While the middleware is a good option if you’re building a single, monolithic SPA without much use for other web pages, I think for most developers, integrating ASP.NET Core into a traditional MVC application makes the most sense. You can typically keep an SPA as a separate folder and treat it as a child-project, without having to do a lot of integration between the two projects.
How to Use the SPA Subdirectory Approach
For example, suppose you have an Angular project in a subdirectory called ClientApp, as seen in Figure 3.
Figure 3
Because this directory has its own package.json, you can just use the CLI of the library you’re working with to do builds and such. You could even develop it in isolation without invoking the ASP.NET Core project, but since I’m usually adding the SPA to an existing Razor view, I generally just run them both.
One issue with this method is that the build directory for the SPA project is usually in this directory. You could use the SpaStaticFiles middleware to solve this, but I usually just change the configuration of the SPA to build into my wwwroot directory. For example, this is what that looks like in the angular.json file (for the Vue.js CLI):
{
...
"projects": {
"ng-core": {
"root": "",
"sourceRoot": "src",
"projectType": "application",
"prefix": "app",
...
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:browser",
"options": {
"outputPath": "../wwwroot/client",
"index": "src/index.html",
"main": "src/main.ts",
"polyfills": "src/polyfills.ts",
"tsConfig": "src/tsconfig.app.json",
...
In this case, I’m just telling the Vue.js CLI to output the build to a subdirectory of the js folder in wwwroot, so I don’t need to configure ASP.NET Core to look into the SPA directory directly. Angular and React both support this kind of output directory, if you don’t want to use the SpaStaticFiles middleware.
By using one or more subdirectories with their own build, you can have many SPA projects (even if they’re different frameworks) in one ASP.NET Core project. But it does have some limitations involving deployment and dependency management. By doing this, each project will have it’s own node_modules directory which means that build times can get very lengthy.
Because you’re configuring the output directory into wwwroot, you don’t need to specify including a directory when you deploy the project, but you will need to tell the MSBuild (e.g. csproj file) to build the SPA project before you deploy. The trick here is to add a Target to your .csproj file:
<Target Name="client-app"
BeforeTargets="ComputeFilesToPublish">
<Exec Command="npm install"
WorkingDirectory="ClientApp"></Exec>
<Exec Command="npm run build"
WorkingDirectory="ClientApp "></Exec>
</Target>
The Target specifies that it should consider something before it computes the files to publish (so before it looks at wwwroot for the files to include). The two ‘Exec’ lines are just a way to run the install and build steps in the ClientApp directory. This is only executed during a publish, so it won’t impact your development cycle.
The big drawback is more about Visual Studio than about ASP.NET Core. Because the project.json is in a subdirectory (not in the root of the ASP.NET Core project), you can’t get the Task Runner Explorer to find the build. This means you’ll have to run it separately. If you’re using Visual Studio Code, Rider, or other IDE, you might already be used to opening console/terminal windows and doing the build yourself so this might not be much of a hardship.
The other limitation is related to this one, more than one package.json to manage. This is the one that hurts me the most. Most of my ASP.NET Core projects already use NPM to manage other dependencies (both dev and production dependencies). Having to manage two or more package.json files makes me a little frustrated. That’s why I usually resolve to use the last of the options — fully integrate the SPA into the ASP.NET Core project.
Fully Integrate an SPA into ASP.NET Core
If you’re already using a build step, why not just make it all work together? That’s the strategy around fully integrating an SPA build into the ASP.NET Core project. This doesn’t mean you have to build your SPA project on every ASP.NET Core build; you can still rely on build watchers and other facilities to speed through development. Building and deploying can be merged with the SPA methods to make your testing and production work in the easiest way possible.
How to Directly Integrate the SPA
This method involves these steps:
- Merging the NPM configuration
- Moving configuration to the root of the project
Let’s go over each of these.
Merging NPM Configuration
To bring your SPA into an ASP.NET Core project, you’ll either need to move the package.json in the root of the project, or merge them together. For example, you might have an existing package.json file that helps you with importing of client-side libraries:
{
"version": "1.0.0",
"name": "mypackage",
"private": true,
"dependencies": {
"jquery": "3.3.1",
"bootstrap": "4.3.1"
}
}
Unfortunately, most SPA projects have a lot of dependencies and bring in other configuration elements. So, after bringing in all of a simple Angular project, it now looks like this:
{
"version": "1.0.0",
"name": "mypackage",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"test": "ng test",
"lint": "ng lint",
"e2e": "ng e2e"
},
"private": true,
"dependencies": {
"jquery": "3.3.1",
"bootstrap": "4.3.1",
"@angular/animations": "^6.1.0",
"@angular/common": "^6.1.0",
"@angular/compiler": "^6.1.0",
"@angular/core": "^6.1.0",
"@angular/forms": "^6.1.0",
"@angular/http": "^6.1.0",
"@angular/platform-browser": "^6.1.0",
"@angular/platform-browser-dynamic": "^6.1.0",
"@angular/router": "^6.1.0",
"core-js": "^2.5.4",
"rxjs": "~6.2.0",
"zone.js": "~0.8.26"
},
"devDependencies": {
"@angular-devkit/build-angular": "~0.8.0",
"@angular/cli": "~6.2.9",
"@angular/compiler-cli": "^6.1.0",
"@angular/language-service": "^6.1.0",
"@types/jasmine": "~2.8.8",
"@types/jasminewd2": "~2.0.3",
"@types/node": "~8.9.4",
"codelyzer": "~4.3.0",
"jasmine-core": "~2.99.1",
"jasmine-spec-reporter": "~4.2.1",
"karma": "~3.0.0",
"karma-chrome-launcher": "~2.2.0",
"karma-coverage-istanbul-reporter": "~2.0.1",
"karma-jasmine": "~1.1.2",
"karma-jasmine-html-reporter": "^0.2.2",
"protractor": "~5.4.0",
"ts-node": "~7.0.0",
"tslint": "~5.11.0",
"typescript": "~2.9.2"
}
}
Once you do this, you should manually drop both node_modules directories (in the main project and the client folder) and just re-install all the packages:
> npm install
Now that NPM is merged, it’s time to move the configuration.
Moving Configuration
You’ll move the configuration files (not the source code) to the root of the project. Depending on what framework you’re using, you’ll have a set of configuration files. For example, with Angular it would be angular.json, tsconfig.json, and tslint.json.
Because you’re moving these files from their relative directory, you’ll need to change any paths in the configuration file to point at the new directories. For example, in angular.json, you’d need to change any path that starts with "src/" to "ClientApp/src/":
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"ng-core": {
"root": "",
"sourceRoot": "ClientApp/src",
"projectType": "application",
"prefix": "app",
"schematics": {...},
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:browser",
"options": {
"outputPath": "wwwroot/client",
"index": "ClientApp/src/index.html",
"main": "ClientApp/src/main.ts",
"polyfills": "ClientApp/src/polyfills.ts",
"tsConfig": "ClientApp/src/tsconfig.app.json",
"assets": [
"ClientApp/src/favicon.ico",
"ClientApp/src/assets"
],
"styles": [
"ClientApp/src/styles.css"
],
"scripts": []
},
"configurations": {
"production": {
"fileReplacements": [
{
"replace": "ClientApp/src/environments/environment.ts",
"with": "ClientApp/src/environments/environment.prod.ts"
}
],
"optimization": true,
"outputHashing": "all",
"sourceMap": false,
"extractCss": true,
"namedChunks": false,
"aot": true,
"extractLicenses": true,
"vendorChunk": false,
"buildOptimizer": true
}
}
},
"serve": { ... },
},
},
},
"defaultProject": "ng-core"
}
At this point, you should be able to build the project. The easiest way to see if it works, is to run the build:
> ng build
If the build succeeds, then your SPA should work in your project. If you’re using Visual Studio, you can use the "Task Runner Explorer" to start the build for you. You can see the different NPM processes in Figure 4.
Figure 4
Because the start process effectively does a rebuild on any change to the code, you can bind it to ‘Project Open’ so the project will continue to build as you code. You can see this in Figure 5.
Figure 5
At this point, you should have everything you need to work in development. But if you’re going to use MSBuild to do any publishing of the project, you need to make another change. Like the previous integration (How to Use the SPA Subdirectory Approach), you still need to specify that the build will be executed before the publish in .csproj:
<Target Name="client-app"
BeforeTargets="ComputeFilesToPublish">
<Exec Command="npm install"
WorkingDirectory="ClientApp"></Exec>
<Exec Command="npm run build"
WorkingDirectory="ClientApp "></Exec>
</Target>
Which to Choose?
A lot of decisions like the ones in this article are more about skill and suitability than architectural purity or best practice. If what you’re doing today is working, just stick with it. In general, I prefer to do full integrations versus the other options. My main reason—when I am adding an SPA (or more than one) to a web app, I rarely want to build one monolithic SPA that is a replacement for a huge enterprise-y application. I want the web to do what it does well, and add content to the web when I need more control, better user experience, and tight user interactions.
In this same way I don’t use ASP.NET Core just for building the API. There are times when server-generation of views is the right thing to do, whether it’s for security of data across the wire (e.g., sending summary data instead of sending potentially high-valued data), improved caching support of SPA views (e.g., a view with a cached list of countries built into the view), or even for using the layout of the site outside of anything for which the SPA is responsible.
This opinion does rely on using SPA frameworks (Angular, React, Vue, etc.) as ways to build islands of functionality into an otherwise-typical website. If you’re building the "one SPA to rule them all" inside your organization and you’ve decided that’s the right way to do it, more power to you. It’s just not the choice that I’d make in most cases. Of course, I’m just another developer... I could be wrong.