There aren’t many modern tutorials online on how to get this stack going. Hopefully this provides value to someone out there. I’ll explain every step along the way. Lets go! 😉
Backend Setup
Let’s scaffold our .NET app.
bash
dotnet new webapi -n MyApp
Adding cors (optional)
It’d be a good idea to add cors. This is completely at your discretion.
csharp
builder.Services.AddCors(options =>
{
options.AddPolicy("OnlyAllowOrigin", corsBuilder =>
{
corsBuilder.WithOrigins($"https://{Environment.GetEnvironmentVariable("HOST")}")
.AllowAnyMethod()
.AllowAnyHeader();
});
});
Applying cors
Let’s only apply cors in production.
csharp
if (app.Environment.IsProduction())
{
app.UseCors("OnlyAllowOrigin");
}
Creating the frontend
Let’s create the frontend. Run this command in the same folder. We’ll be putting it under the /Client
folder.
You can use whatever client-side framework you like, Vue, Solid, Qwick, React, etc. This is framework agnostic.
bash
npm create vite@latest Client
Now, we’ll need to serve our app through the wwwroot
folder.
Add the following to your vite.config.ts
.
typescript
export default defineConfig({
build: {
outDir: "../wwwroot",
emptyOutDir: true,
},
});
Serving the frontend
Going over to our .csproj
, we need to make sure that dotnet ignores our Client
folder when hot reloading and includes it in our assets when we publish.
Before we publish, we want to install our dependencies and build our static assets, specified by the AfterTargets
property.
xml
<Target Name="Build App" Condition="'$(DotNetWatchBuild)' != 'true'" AfterTargets="BeforePublish">
<Exec WorkingDirectory="Client" Command="npm install" />
<Exec WorkingDirectory="Client" Command="npm run build"/>
<ItemGroup>
<ClientSource Include="wwwroot\**" />
<ContentWithTargetPath Include="@(ClientSource->'%(FullPath)')" RelativePath="%(ClientSource.Identity)" TargetPath="%(ClientSource.Identity)" CopyToPublishDirectory="Always" />
</ItemGroup>
</Target>
To make sure our C# app serves the the static files after it’s built, we need to add the following:
csharp
if (app.Environment.IsProduction())
{
# optional
# app.UseCors("OnlyAllowOrigin");
# optional
# app.UseHsts();
app.UseStaticFiles();
app.MapFallbackToFile("index.html");
}
In development, we’ll be hot reloading our two projects side by side. If we try to call our backend without a proxy, we’ll run into cors issues in the browser. Add the following to your vite config. The proxy will only work in development. Find more information here: Vite Server Options.
typescript
export default defineConfig({
server: {
proxy: {
"/api": {
target: "http://localhost:8080", // the port of the backend.
changeOrigin: true,
secure: false,
ws: true,
},
},
},
});
Bringing it all together
Running it locally
Open two terminal instances
In the first we run:
bash
dotnet run watch
In another we run:
bash
npm run dev --prefix Client
Let’s add an endpoint to check if things are working.
Program.cs
var api = app.MapGroup("/api");
api.MapGet("/ping", () => "pong");
Let’s call this endpoint on the frontend.
vue
<script setup lang="ts">
const { data } = await useFetch("/api/ping");
</script>
<template>
<div>Server: {{ data }}</div>
</template>
Deployment
Feel free to use the following Dockerfile
.
dockerfile
FROM node:23.5.0 AS node
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
# Copy node binary to .NET image, cached
COPY --from=node . .
WORKDIR /src
COPY . ./
RUN dotnet restore
# we build the app here
# can further optimize to cache node_modules but its a small app so fine for now
RUN dotnet publish -c Release
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS final
WORKDIR /app
COPY --from=build /src/bin/Release/net9.0/publish ./
EXPOSE 8080
ENV HOST="localhost:8080"
ENTRYPOINT ["dotnet", "MyApp.dll"]