z The Flat Field Z
[ @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ ]

Vite SPA with ASP.NET Core Backend for Frontend

Single-page-applications (SPAs) have a lot of desirable characteristics such as a high level of interactivity and ease of deployment. Unfortunately the security story is not as strong. Without a backend they have to store sensitive credentials they need to access APIs on the client side. These difficulties are further compounded by increasingly strict browser cookie defaults that make implementing more complicated auth flows like OpenID Connect very difficult.

One useful technique to overcome this is the Backend for Frontend pattern. What it entails is writing a thin backend that serves up the SPA and forwards requests from the frontend to whatever APIs the frontend calls. Sensitive credentials can then be safely stored on the server side and injected into the forwarded requests.

This article will cover how to configure a hot reloading dev setup as well as optimized release Docker images. We will use Typescript + Vue for the frontend with Vite for build tooling; though you could use any framework supported by Vite. The backend will be in ASP.NET Core using ProxyKit for the request forwarding.

You will need Docker, the latest .NET SDK, the latest Node.JS, and a text editor.

The Backend

We will start with the backend. First we'll need a project:

<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>net6.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.SpaServices.Extensions" Version="6.0.0" />
    <PackageReference Include="ProxyKit" Version="2.3.4" />
  </ItemGroup>

</Project>

We are using two Nuget packages:

  1. Microsoft.AspNetCore.SpaServices.Extensions: Used to serve the SPA from ASP.NET Core. This will work with anything Vite supports.
  2. ProxyKit: Used to forward requests from the backend to an API.

In order to support hot reloading on the backend we will need launchSettings.json to look like:

{
  "profiles": {
    "ViteBff": {
      "commandName": "Project",
      "hotReloadProfile": "aspnetcore",
      "applicationUrl": "http://localhost:5000",
      "environmentVariables": {
        "ASPNETCORE_ENVIRONMENT": "Development"
      }
    }
  }
}

Then we configure the backend in Program.cs:

using ProxyKit;

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
builder.Services.AddRazorPages();
builder.Services.AddResponseCompression();
builder.Services.AddProxy();

var app = builder.Build();

app.UseResponseCompression();
app.UseStaticFiles();
app.UseRouting();
// TODO: Setup auth here.
app.MapRazorPages();

app.Map("/api", api => {
  api.RunProxy(async context => {
    var forwardContext = context.ForwardTo("https://postman-echo.com");
    // TODO: Add auth header here.
    return await forwardContext.Send();
  });
});

var isDev = app.Services.GetRequiredService<IWebHostEnvironment>().IsDevelopment();
if (isDev)
  app.UseSpa(spa => spa.UseProxyToSpaDevelopmentServer("http://localhost:3000"));
else
  app.UseSpa(spa => {});

app.Run();

There is a fair bit going on here. The app will have a single page (Index.cshtml) which will be a Razor page, so we add support for Razor pages here.

In production builds the SPA will be static files served up by the backend, so we add support for serving up static files. We also add support for serving up those static files with Brotli/Gzip compression.

Using the Microsoft.AspNetCore.SpaServices.Extensions Nuget package we add support for serving up the SPA. It is configured to use the Vite Dev server for dev, and static files for prod.

Using the ProxyKit Nuget package we add support for forwarding calls to an API. In this case we forward every call that starts with "api" to the Postman echo service.

There are a couple of TODOs in here where you would add auth logic. The first is where you would wire up the auth middleware for your auth provider. ASP.NET Core has a lot of libraries for this, so chances are your auth provider is already covered. The second is where we inject the auth into the forwarded request. This will likely look like pulling a token out of the session, and then adding it as a header.

The last piece of the backend is an index page:

@page
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" href="/favicon.ico" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vue + TS + Vite + ASP.NET Core App</title>
  </head>
  <body>
    <div id="app"></div>

    <environment include="Development">
        <script type="module" src="~/@@vite/client"></script>
        <script type="module" src="~/src/main.ts"></script>
    </environment>

    <environment exclude="Development">
        <script type="module" asp-src-include="~/assets/index.*.js"></script>
        <script type="module" asp-src-include="~/assets/vendor.*.js"></script>
    </environment>

    <environment exclude="Development">
        <link rel="stylesheet" asp-href-include="~/assets/index.*.css">
    </environment>
  </body>
</html>

This page will load resources from the Vite dev server in dev, and from static files for prod.

The Frontend

JavaScript build tooling is generally a mess, however there is a lot of next generation frontend tooling coming online now. This next generation tooling generally requires much less configuration, builds much faster, and supports hot reloading. We will be using one of those packages called Vite here. Vite supports a number of frameworks, but we will be using Vue 3 + Typescript.

You will generally generate a Vite based project using Vite itself: npm init vite@latest. This will setup everything you need for your selected language and framework. Then you will need to install the packages: npm install. Finally we will need an HTTP client, so let's install one: npm install ky.

The default project will mostly work with ASP.NET Core, but we need to make one change to the Vite config:

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [vue()],
  server: {
    hmr: {
      protocol: 'ws'
    }
  }
})

All we did here was change the server protocol to web sockets.

We will also change the App.vue to make a forwarded call through the backend:

<template>
  <p>{{ message }}</p>
</template>

<script lang="ts">
import { defineComponent, ref } from 'vue'
import ky from 'ky'

export default defineComponent({
  name: 'App',
  setup () {
    const message = ref<string>('Loading...')
    ky.get('api/get?hello=world').text().then(result => {
      message.value = result
    })

    return {
      message
    }
  }
})
</script>

Because this component is making a call to API it will be forwarded by the backend to the Postman echo service.

Build

It turns out we already have our development build working. In the same directory as the ASP.NET Core project run dotnet watch , and then in the same directory as the Node.js project run npm run dev. Now the site will be at http://localhost:5000. This will hot reload the backend and frontend as you edit them.

We do have some more work to do around the production build. As usual we'll do our building in a Docker container, so let's add a Dockerfile:

FROM node:15 AS frontend
WORKDIR /src
COPY src/ClientApp/package.json .
COPY src/ClientApp/package-lock.json .
RUN npm ci
COPY src/ClientApp/ .
RUN npm run build

FROM mcr.microsoft.com/dotnet/sdk:6.0 AS backend
WORKDIR /src
COPY src/ .
COPY --from=frontend /src/dist ./wwwroot
RUN dotnet restore
RUN dotnet build -c Release
RUN dotnet publish -c Release -o /app

FROM mcr.microsoft.com/dotnet/aspnet:6.0 AS final
EXPOSE 80
WORKDIR /app
COPY --from=backend /app .

ENTRYPOINT ["dotnet", "ViteBff.dll"]

This will build the frontend, build the backend, add the frontend resources to the backend, and then run the backend. We'll also want a Docker compose file to simplify testing the production build locally:

services:
  backend:
    network_mode: bridge
    build: .
    environment:
      - "ASPNETCORE_URLS=http://*:5000"
    ports:
      - "5000:5000"

To test the production build we just do a docker compose up. Like the dev build the site will be at http://localhost:5000.

Working Example

A full working example can be found here.