Introduction
A single-page application (SPA) is a web application or website that interacts with the user by dynamically rewriting the current web page with new data from the web server, instead of the default method of a web browser loading entire new pages. The goal is faster transitions that make the website feel more like a native app.
In an SPA, a page refresh never occurs; instead, all necessary HTML, JavaScript, and CSS code is either retrieved by the browser with a single page load, or the proper resources are dynamically loaded and added to the page as necessary, usually in response to user actions.
Single-page applications perform well compared to traditional web applications. We can create SPAs in Angular or React very easily.
When you are creating an Angular or React application, you need to create a backend application. Usually, we use .NET, Node, Java, PHP or Python as backend applications. We must create and run separate backend applications. Visual Studio supports creating both Angular (or react) and .NET Core in a single application. The advantage is that we can host(publish) both these applications in a single domain as a single application.
Visual Studio 2022 latest version (17.1.0 as of 6th March 2022) allows us to create a single-page application with .NET 6.0 and Angular 13. (Earlier versions of Visual Studio 2022 create Angular 12 version by default)
We can see all the steps to create an SPA and we will also see how to publish this application into Azure.
Create ASP.NET Core and Angular application in Visual Studio 2022
Currently Visual Studio has two templates for Angular applications.

We can choose the first template ASP.NET Core with Angular. The second template is used to create standalone Angular applications.
We can give a valid file name and click the next button.

I have unchecked the HTTPS configuration. You can choose HTTPS if needed.
Our project will be created in a few moments.
We can see the structure of the entire SPA application.
If you open the project file (.csproj) you will see the details below.

By default, Angular 13 application is created inside a ClientApp folder. It is set inside the SpaRoot property. If you rename the ClientApp folder, you must change the property value in this project file as well. Angular running port is set inside the SpaProxyServerUrl property. If you want to change the Angular running port number, you can change inside this property. At the same time, you must change the package.json file of Angular application also.

We can see the Angular app structure.

As we discussed earlier, Angular app is created under the ClientApp folder. The project structure is like a normal Angular 13 project, but it has two modules. It also has a new file named proxy.conf.js. This is a configuration file which has the ASP.NET backend controller names. We will discuss more about this file later.
We will be deploying this application to Azure. Hence, we can create a simple working application instead of default template. We will be creating an application to analyze the C# Corner posts (articles/blogs) details of an author.
I have already written two detailed articles about this topic and published. Please read the below articles.
Easily Do Web Scraping In .NET Core 6.0
Easily Create Charts In Angular 13 with Dynamic Data
Complete the Backend ASP.NET Core 6.0 application
We must install the libraries below using NuGet package manger.
- HtmlAgilityPack
- Microsoft.EntityFrameworkCore.SqlServer
- Microsoft.EntityFrameworkCore.Tools
We are using Entity framework in this application.
We can add database connection string and parallel task counts inside the appsettings.json file.
appsettings.json
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"ConnStr": "Data Source=(localdb)\\MSSQLLocalDB;Initial Catalog=AnalyticsDB;Integrated Security=True;ApplicationIntent=ReadWrite;MultiSubnetFailover=False"
},
"ParallelTasksCount": 20
}
Database connection string will be used by entity framework to connect SQL database and parallel task counts will be used by web scraping parallel foreach code.
We can create a Feed class inside a Models folder. This class will be used to get required information from C# Corner RSS feeds.
Feed.cs
namespace Angular13ASP.NET6SPA.Models
{
public class Feed
{
public string Link { get; set; }
public string Title { get; set; }
public string FeedType { get; set; }
public string Author { get; set; }
public string Content { get; set; }
public DateTime PubDate { get; set; }
public Feed()
{
Link = "";
Title = "";
FeedType = "";
Author = "";
Content = "";
PubDate = DateTime.Today;
}
}
}
We can create an ArticleMatrix class inside the Models folder. This class will be used to get information for each article/blog once we get it after web scraping.
ArticleMatrix.cs
using System.ComponentModel.DataAnnotations.Schema;
namespace Angular13ASP.NET6SPA.Models
{
public class ArticleMatrix
{
public int Id { get; set; }
public string? AuthorId { get; set; }
public string? Author { get; set; }
public string? Link { get; set; }
public string? Title { get; set; }
public string? Type { get; set; }
public string? Category { get; set; }
public string? Views { get; set; }
[Column(TypeName = "decimal(18,4)")]
public decimal ViewsCount { get; set; }
public int Likes { get; set; }
public DateTime PubDate { get; set; }
}
}
We can create an Authors class inside the Models folder.
Authors.cs
namespace Angular13ASP.NET6SPA.Models
{
public class Authors
{
public string? AuthorId { get; set; }
public string? Author { get; set; }
public int Count { get; set; }
}
}
We can create Category class.
Category.cs
namespace Angular13ASP.NET6SPA.Models
{
public class Category
{
public string? Name { get; set; }
public int Count { get; set; }
}
}
We can create our DB context class for Entity framework.
MyDbContext.cs
using Microsoft.EntityFrameworkCore;
namespace Angular13ASP.NET6SPA.Models
{
public class MyDbContext : DbContext
{
public MyDbContext(DbContextOptions<MyDbContext> options)
: base(options)
{
}
public DbSet<ArticleMatrix>? ArticleMatrices { get; set; }
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
}
}
}
We can create our API controller AnalyticsController and add web scraping code inside it.
AnalyticsController.cs
using Angular13ASP.NET6SPA.Models;
using HtmlAgilityPack;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using System.Globalization;
using System.Net;
using System.Xml.Linq;
namespace Angular13ASP.NET6SPA.Controllers
{
[Route("[controller]")]
[ApiController]
public class AnalyticsController : ControllerBase
{
readonly CultureInfo culture = new("en-US");
private readonly MyDbContext _dbContext;
private readonly IConfiguration _configuration;
public AnalyticsController(MyDbContext context, IConfiguration configuration)
{
_dbContext = context;
_configuration = configuration;
}
[HttpPost]
[Route("CreatePosts/{authorId}")]
public async Task<bool> CreatePosts(string authorId)
{
try
{
XDocument doc = XDocument.Load("https://www.c-sharpcorner.com/members/" + authorId + "/rss");
if (doc == null)
{
return false;
}
var entries = from item in doc.Root.Descendants().First(i => i.Name.LocalName == "channel").Elements().Where(i => i.Name.LocalName == "item")
select new Feed
{
Content = item.Elements().First(i => i.Name.LocalName == "description").Value,
Link = (item.Elements().First(i => i.Name.LocalName == "link").Value).StartsWith("/") ? "https://www.c-sharpcorner.com" + item.Elements().First(i => i.Name.LocalName == "link").Value : item.Elements().First(i => i.Name.LocalName == "link").Value,
PubDate = Convert.ToDateTime(item.Elements().First(i => i.Name.LocalName == "pubDate").Value, culture),
Title = item.Elements().First(i => i.Name.LocalName == "title").Value,
FeedType = (item.Elements().First(i => i.Name.LocalName == "link").Value).ToLowerInvariant().Contains("blog") ? "Blog" : (item.Elements().First(i => i.Name.LocalName == "link").Value).ToLowerInvariant().Contains("news") ? "News" : "Article",
Author = item.Elements().First(i => i.Name.LocalName == "author").Value
};
List<Feed> feeds = entries.OrderByDescending(o => o.PubDate).ToList();
string urlAddress = string.Empty;
List<ArticleMatrix> articleMatrices = new();
_ = int.TryParse(_configuration["ParallelTasksCount"], out int parallelTasksCount);
Parallel.ForEach(feeds, new ParallelOptions { MaxDegreeOfParallelism = parallelTasksCount }, feed =>
{
urlAddress = feed.Link;
var httpClient = new HttpClient
{
BaseAddress = new Uri(urlAddress)
};
var result = httpClient.GetAsync("").Result;
string strData = "";
if (result.StatusCode == HttpStatusCode.OK)
{
strData = result.Content.ReadAsStringAsync().Result;
HtmlDocument htmlDocument = new();
htmlDocument.LoadHtml(strData);
ArticleMatrix articleMatrix = new()
{
AuthorId = authorId,
Author = feed.Author,
Type = feed.FeedType,
Link = feed.Link,
Title = feed.Title,
PubDate = feed.PubDate
};
string category = "Uncategorized";
if (htmlDocument.GetElementbyId("ImgCategory") != null)
{
category = htmlDocument.GetElementbyId("ImgCategory").GetAttributeValue("title", "");
}
articleMatrix.Category = category;
var view = htmlDocument.DocumentNode.SelectSingleNode("//span[@id='ViewCounts']");
if (view != null)
{
articleMatrix.Views = view.InnerText;
if (articleMatrix.Views.Contains('m'))
{
articleMatrix.ViewsCount = decimal.Parse(articleMatrix.Views[0..^1]) * 1000000;
}
else if (articleMatrix.Views.Contains('k'))
{
articleMatrix.ViewsCount = decimal.Parse(articleMatrix.Views[0..^1]) * 1000;
}
else
{
_ = decimal.TryParse(articleMatrix.Views, out decimal viewCount);
articleMatrix.ViewsCount = viewCount;
}
}
else
{
articleMatrix.ViewsCount = 0;
}
var like = htmlDocument.DocumentNode.SelectSingleNode("//span[@id='LabelLikeCount']");
if (like != null)
{
_ = int.TryParse(like.InnerText, out int likes);
articleMatrix.Likes = likes;
}
articleMatrices.Add(articleMatrix);
}
});
_dbContext.ArticleMatrices.RemoveRange(_dbContext.ArticleMatrices.Where(x => x.AuthorId == authorId));
foreach (ArticleMatrix articleMatrix in articleMatrices)
{
await _dbContext.ArticleMatrices.AddAsync(articleMatrix);
}
await _dbContext.SaveChangesAsync();
return true;
}
catch
{
return false;
}
}
[HttpGet]
[Route("GetAuthors")]
public IQueryable<Authors> GetAuthors()
{
return _dbContext.ArticleMatrices.GroupBy(author => author.AuthorId)
.Select(group =>
new Authors
{
AuthorId = group.FirstOrDefault().AuthorId,
Author = group.FirstOrDefault().Author,
Count = group.Count()
})
.OrderBy(group => group.Author);
}
[HttpGet]
[Route("GetCategory/{authorId}")]
public IQueryable<Category> GetCategory(string authorId)
{
return from x in _dbContext.ArticleMatrices.Where(x => x.AuthorId == authorId).GroupBy(x => x.Category)
select new Category
{
Name = x.FirstOrDefault().Category,
Count = x.Count()
};
}
}
}
Please note that route of the API controller should not carry “api/” prefix. Otherwise, SPA will not work.
We have added a new API controller now. We must add this controller entry in Angular proxy configuration file inside the context property. Otherwise, API call from Angular will fail.
proxy.conf.js
const { env } = require('process');
const target = env.ASPNETCORE_HTTPS_PORT ? `https://localhost:${env.ASPNETCORE_HTTPS_PORT}` :
env.ASPNETCORE_URLS ? env.ASPNETCORE_URLS.split(';')[0] : 'http://localhost:38078';
const PROXY_CONFIG = [
{
context: [
"/weatherforecast",
"/analytics",
],
target: target,
secure: false,
headers: {
Connection: 'Keep-Alive'
}
}
]
module.exports = PROXY_CONFIG;
Also note that the above context entry is case sensitive.
Finally, we can make the changes below inside the Program.cs file.
Program.cs
using Angular13ASP.NET6SPA.Models;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
ConfigurationManager configuration = builder.Configuration;
// Add services to the container.
builder.Services.AddControllersWithViews();
builder.Services.AddDbContext<MyDbContext>(options => options.UseSqlServer(configuration.GetConnectionString("ConnStr")));
var app = builder.Build();
// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
}
app.UseStaticFiles();
app.UseRouting();
app.MapControllerRoute(
name: "default",
pattern: "{controller}/{action=Index}/{id?}");
app.MapFallbackToFile("index.html"); ;
app.Run();














Guled SariPosted Oct 19, 2023, 7:25 PM
Hey there. Great article... Thanks for sharing. I recently tried out an SPA project like this using .NET 6 and Angular 15 and had this question...When enabling the Google Signin for Identity's externalLogin option, should you use the Angular URI running on port 44456 or the Dotnet Uri running on port 5172? I tried both and still couldn't make it work? Any insights?
Michael DPosted Apr 4, 2023, 8:57 AM
Hey there, greate article ... have you already tried to use GitLab and set up a CI/CD with Azure Web App Service (using Kudu)? ... I tried this but I got an error while the deployment ... the code was pulled successfully from GitLab but while building it I got an error which says: "npm WARN read-shrinkwrap This version of npm is compatible with lockfileVersion@1, but package-lock.json was generated for lockfileVersion@2. I?ll try to do my best with it! npm ERR! cb() never called!" .... Anyone an idea whats wrong here and how I can fix this error? :) what would be amazing :) thanks guys
Greg MetzgerPosted Jun 5, 2022, 10:13 PM
After downloading the code I ran the migration script and everything worked perfect!
Rajendr Singh DodiyaPosted Apr 7, 2022, 4:19 PM
Good article,
Mohsin AzamPosted Apr 1, 2022, 6:06 PM
Good article.
Jayateerth KulkarniPosted Mar 18, 2022, 8:36 AM
Hi Sarathlal Saseendran, I was trying to publish app using `dotnet publish -c Release -o published` which publishes successfully. However when I run `dotnet published/Angular13ASP.NET6SPA.dll` i could see that the application starts info: Microsoft.Hosting.Lifetime[14] Now listening on: http://localhost:5000 info: Microsoft.Hosting.Lifetime[14] Now listening on: https://localhost:5001 info: Microsoft.Hosting.Lifetime[0] Application started. Press Ctrl+C to shut down. info: Microsoft.Hosting.Lifetime[0] Hosting environment: Production info: Microsoft.Hosting.Lifetime[0] Content root path: D:\Learning Habits\Angular13ASP.NET6SPA\Angular13ASP.NET6SPA\ and when trying to launch in browser it return 404 Error. Is there anything which I'm missing here?
Getulio RodriguezPosted Mar 10, 2022, 4:10 PM
Sensacional!! :)
Jes KirkupPosted Mar 10, 2022, 8:49 AM
Great detailed article, very nicely explained. Do you see any disadvantages to the new SPA proxying model that Microsoft have introduced for .NET Core 6? Is it possible still to use the old approach with SpaProxyingExtensions UseProxyToSpaDevelopmentServer Method? Thanks!