Off late, Single-Page Applications or SPAs have become the most sought after client facing application stacks, for their light-weight and high performance nature. In this architecture, the server focuses on data logic and supplies data to the client in the form of RESTful APIs, while the client application renders the fetched data onto a fluid and dynamic layout.
This can also help reduce load on the webserver for rendering and serving HTML content over the network while giving user a better user experience. Client-side frameworks such as Angular, ReactJs and Ionic have matured alot in developing most efficient SPAs. In this article, let’s talk about how we can build an SPA inside an existing ASP.NET Core application, powered by Angular.
Assume we have an ASP.NET Core application which is the data source for an Angular application. Now for deployment, we can have two options: have two instances running for the client application and the server application, with the client application configured with the server endpoint. This is the way for most of the real-world application deployments we see these days.
An SPA is just an index.html page with a few js files for runtime and client logic which again needs to be hosted in a webserver such as IIS or Apache.
Since we would also need to have the client application maintain the server address for communication, there has been a new approach in sandwiching an angular SPA inside an ASP.NET Core API.
In this approach, both the applications can be built and deployed under a single instance and both the applications are local to each other. The ASPNETCORE application also serves the client application to the user upon calling the respective routes. This requires installing a couple of libraries into the ASP.NET Core API as well as tweaking the csproj file to accommodate angular build activities.
Let’s understand and implement these step by step.
Step 1: Begin by copying the Angular application into the ASPNETCORE project under a sub directory called ClientApp. This resides under the root directory, along with the controllers and other projects. Since we’re using an ASPNETCORE API project, we don’t have any wwwroot folder or Views folder for that sake so just keep it in mind.
Step 2: In the ASPNETCORE project install the below package which supports SPA building and rendering.
<PackageReference Include="Microsoft.AspNetCore.SpaServices.Extensions" Version="3.1.3" />
Step 3: In the Startup class we would need to add a few services and middlewares to support SPA. Technically, an SPA is just a bunch of static files under the ASPNETCORE project which the server application would route to for specific requests. And to enable this, we would add support for the server to access these static files.
This is done by the UseStaticFiles() middleware and UseSpa() middleware for SPA support.
public void ConfigureServices(IServiceCollection services)
{
services.AddControllersWithViews();
// In production, the Angular files will be served from this directory
services.AddSpaStaticFiles(configuration =>
{
configuration.RootPath = "ClientApp/dist";
});
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
app.UseStaticFiles();
// In Production Environment
// Serve Spa static files
if (!env.IsDevelopment())
{
app.UseSpaStaticFiles();
}
// other ASPNETCORE Routing Code
// Spa middleware to enable
// SPA request handling
app.UseSpa(spa =>
{
// The directory from which the
// SPA files shall be served for
// client requests
spa.Options.SourcePath = "ClientApp";
// When in Development,
// Since the SPA app is not build
// use npm start command to run
// the node server for local run
if (env.IsDevelopment())
{
spa.UseAngularCliServer(npmScript: "start");
}
});
}
Step 4: In the angular.json file of the client application which is under the ClientApp directory we ensure the angular build command configuration so that on angular build, the files are created under the /dist folder relative to the Client app path.
"build": {
"builder": "@angular-devkit/build-angular:browser",
"options": {
"progress": false,
"extractCss": true,
"outputPath": "dist", <-- ensure this path
"index": "src/index.html",
"main": "src/main.ts",
"polyfills": "src/polyfills.ts",
"tsConfig": "src/tsconfig.app.json",
"assets": ["src/assets"],
"styles": [
"node_modules/bootstrap/dist/css/bootstrap.min.css",
"src/styles.css"
],
"scripts": []
}
}
This is important because during runtime the ASPNETCORE application looks for the Spa files under the path /ClientApp/dist folder which is specified in the services.AddSpaStaticFiles() method.
Step 5: Finally, we wire up both the ClientApp and ASPNETCORE app together during project build via the csproj with the below changes.
<ItemGroup>
<!-- Don't publish the SPA source files, but do show them in the project files list -->
<Content Remove="$(SpaRoot)**" />
<None Remove="$(SpaRoot)**" />
<None Include="$(SpaRoot)**" Exclude="$(SpaRoot)node_modules**" />
</ItemGroup>
<!-- Debug run configuration -->
<!-- Check for Nodejs installation, install in the ClientApp -->
<Target Name="DebugEnsureNodeEnv" BeforeTargets="Build" Condition=" '$(Configuration)' == 'Debug' And !Exists('$(SpaRoot)node_modules') ">
<!-- Ensure Node.js is installed -->
<Exec Command="node --version" ContinueOnError="true">
<Output TaskParameter="ExitCode" PropertyName="ErrorCode" />
</Exec>
<Error Condition="'$(ErrorCode)' != '0'" Text="Node.js is required to build and run this project. To continue, please install Node.js from https://nodejs.org/, and then restart your command prompt or IDE." />
<Message Importance="high" Text="Restoring dependencies using 'npm'. This may take several minutes..." />
<Exec WorkingDirectory="$(SpaRoot)" Command="npm install" />
</Target>
<!-- Publish time tasks: run npm build along with project publish -->
<Target Name="PublishRunWebpack" AfterTargets="ComputeFilesToPublish">
<!-- As part of publishing, ensure the JS resources are freshly built in production mode -->
<Exec WorkingDirectory="$(SpaRoot)" Command="npm install" />
<Exec WorkingDirectory="$(SpaRoot)" Command="npm run build -- --prod" />
<Exec WorkingDirectory="$(SpaRoot)" Command="npm run build:ssr -- --prod" Condition=" '$(BuildServerSideRenderer)' == 'true' " />
<!-- Include the newly-built files in the publish output -->
<ItemGroup>
<DistFiles Include="$(SpaRoot)dist**; $(SpaRoot)dist-server**" />
<DistFiles Include="$(SpaRoot)node_modules**" Condition="'$(BuildServerSideRenderer)' == 'true'" />
<ResolvedFileToPublish Include="@(DistFiles->'%(FullPath)')" Exclude="@(ResolvedFileToPublish)">
<RelativePath>%(DistFiles.Identity)</RelativePath>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
</ResolvedFileToPublish>
</ItemGroup>
</Target>
In all these configurations, we specify the ClientApp rootpath as $(SpaRoot) which we define at the top of the csproj file as below:
<PropertyGroup>
<TargetFramework>netcoreapp3.1</TargetFramework>
<TypeScriptCompileBlocked>true</TypeScriptCompileBlocked>
<TypeScriptToolsVersion>Latest</TypeScriptToolsVersion>
<IsPackable>false</IsPackable>
<!-- the path picked up by all the processing -->
<SpaRoot>ClientApp</SpaRoot>
<DefaultItemExcludes>$(DefaultItemExcludes);$(SpaRoot)node_modules**</DefaultItemExcludes>
<!-- Set this to true if you enable server-side prerendering -->
<BuildServerSideRenderer>false</BuildServerSideRenderer>
</PropertyGroup>
Once these things are all done, we can just publish our project
and run to see our changes coming into effect.