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
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.
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.