ASP.NET Core + Vite

Programming
Published on February 23, 2025 Last updated on October 4, 2025

Introduction

When you want to compile TypeScript in ASP.NET Core (Visual Studio), you have several options.

Method Overview / Notes
TypeScript SDK Included with Visual Studio. Now deprecated. 1
Microsoft.TypeScript.MSBuild Official Microsoft NuGet package
tsc (Node.js) Compiles TypeScript using the tsc command
Webpack + tsc Bundling is standard. HMR can be configured.
Vite + tsc Uses ESM for HMR, allowing fast hot-swapping during development.

For Visual Studio, Microsoft.TypeScript.MSBuild is the standard choice. Visual Studio supports the compileOnSave option in tsconfig.json, which automatically compiles ts files when saved. This is a simple and good option for straightforward 1:1 conversion of ts files to js files. However, when you want to do more, such as import other files or split a long ts file, it can be difficult due to various limitations (though not impossible 2).

Furthermore, since ts files are frequently updated during development, I started wanting to use HMR (Hot Module Replacement). Given its mechanism, Vite, which hot-swaps modules using ESM (ECMAScript Modules), seemed faster than Webpack, so I decided to adopt a configuration using Vite.

Below are the steps to use Vite in an ASP.NET Core project.

Prerequisites

  • An ASP.NET Core project is already created.
  • Node.js is installed.
  • Visual Studio 2022 or later.

Setting up the Vite build environment

Work in the project folder.

1Install Vite

npm install vite --save-dev

2Install TypeScript

npm install typescript --save-dev

The package.json file will look like this:

{
  "devDependencies": {
    "typescript": "^5.9.3",
    "vite": "^7.1.7"
  }
}

3Create vite.config.ts

Let's try building with npx vite build.

> npx vite build
>>
vite v7.1.7 building for production...
✓ 0 modules transformed.
✗ Build failed in 9ms
error during build:
Could not resolve entry module "index.html".

We got an error indicating index.html is missing.
Vite defaults to index.html directly under the project root as its entry point.

Create the configuration file vite.config.ts to specify the entry point file. vite.config.ts should be created directly under the project root.

// vite.config.ts
import { defineConfig } from 'vite'

export default defineConfig({
    build: {
        rollupOptions: {
            input: {
                hoge: 'index.ts',
            }
        }
    }
})

Create an index.ts file directly under the project root and try npx vite build again.

// index.ts
console.log('hoge');
> npx vite build
>>
vite v7.1.7 building for production...
✓ 1 modules transformed.
dist/assets/hoge-BBL_kPP-.js  0.02 kB │ gzip: 0.04 kB
✓ built in 40ms

It completed successfully. The compiled JavaScript is output to the dist/assets folder.

// dist\assets\hoge-BBL_kPP-.js
console.log("hoge");

The -BBL_kPP- part of the filename is a hash value generated by Vite. If the output filename is not explicitly specified, such a hash value is appended by default.
Next, let's specify the output location.

4Specify the output folder

Since this is an ASP.NET Core app, we want to output to the wwwroot folder.
The following settings allow output to the wwwroot/js folder.

// vite.config.ts
import { defineConfig } from 'vite'

export default defineConfig({
    build: {
        outDir: 'wwwroot/js',   // Output directory
        emptyOutDir: false,     // Do not empty the directory
        rollupOptions: {
            input: {
                hoge: 'index.ts',
            }
        }
    }
})

emptyOutDir is a setting to empty the output folder before compilation. The default value is true, so we set it to false here, considering cases where other JavaScript files might be used together.

> npx vite build
>>
vite v7.1.7 building for production...
✓ 1 modules transformed.
wwwroot/js/assets/hoge-BBL_kPP-.js  0.02 kB │ gzip: 0.04 kB
✓ built in 35ms

It was output to the specified output folder.
Next, we will change the output filename.

5Specify the output filename

Adding a hash value to the filename is a means of cache busting, ensuring the browser loads the latest js file when the code changes. However, in ASP.NET Core, asp-append-version can handle cache busting, so there's no need to include hash values in the filename. Let's use a fixed filename.

// vite.config.ts
import { defineConfig } from 'vite'

export default defineConfig({
    build: {
        outDir: 'wwwroot/js',   // Output directory
        emptyOutDir: false,     // Do not empty the directory
        rollupOptions: {
            input: {
                hoge: 'index.ts',
            },
            output: {
                entryFileNames: `[name].js` // Output file name
            }
        }
    }
})
> npx vite build
>>
vite v7.1.7 building for production...
✓ 1 modules transformed.
wwwroot/js/hoge.js  0.02 kB │ gzip: 0.04 kB
✓ built in 36ms

It now outputs with a fixed filename.

You can reference it from cshtml as follows:

// index.cshtml
<script src="~/js/hoge.js" asp-append-version="true"></script>

6Specify the ts file folder

We were placing TypeScript files directly under the project root, but now we'll create a ClientApp folder and place them there.

// vite.config.ts
import { defineConfig } from 'vite'

export default defineConfig({
    build: {
        outDir: 'wwwroot/js',   // Output directory
        emptyOutDir: false,     // Do not empty the directory
        rollupOptions: {
            input: {
                hoge: 'ClientApp/index.ts',
            },
            output: {
                entryFileNames: `[name].js` // Output file name
            }
        }
    }
})

7Support multiple file outputs

If you want to output js files from multiple entry points, write it as follows.

// vite.config.ts
import { defineConfig } from 'vite'

export default defineConfig({
    build: {
        outDir: 'wwwroot/js',   // Output directory
        emptyOutDir: false,     // Do not empty the directory
        rollupOptions: {
            input: {
                hoge: 'ClientApp/index.ts',
                fuga: 'ClientApp/index2.ts',
            },
            output: {
                entryFileNames: `[name].js` // Output file name
            }
        }
    }
})
> npx vite build
>>
vite v7.1.7 building for production...
✓ 2 modules transformed.
wwwroot/js/hoge.js  0.02 kB │ gzip: 0.04 kB
wwwroot/js/fuga.js  0.02 kB │ gzip: 0.04 kB
✓ built in 42ms

hoge and fuga specified in input are applied to [name] in the output files.
This is sufficient, but we want to avoid modifying vite.config.ts every time we add an entry point. Let's make it a bit more generic.

8Target all index.ts files

We will use the following rules:

  • All index.ts files within the ClientApp folder are entry points.
  • Output js files in the same hierarchical structure as the folders.

Example)

ClientApp/pages/
 ├─ hoge/
 │   └─ index.ts
 └─ fuga/
     └─ piyo/
         └─ index.ts
wwwroot/js/pages/
 ├─ hoge.js
 └─ fuga/piyo.js

The goal is to align with the folder structure of Razor Pages.
vite.config.ts will be as follows:

// vite.config.ts
import { defineConfig } from 'vite';
import { relative, dirname, resolve } from 'path';
import fs from 'fs';

function collectEntries(rootDir: string): Record<string, string> {
    const entries: Record<string, string> = {};

    function walk(dir: string) {
        for (const item of fs.readdirSync(dir, { withFileTypes: true })) {
            const fullPath = resolve(dir, item.name);
            if (item.isDirectory()) {
                walk(fullPath);
            } else if (item.isFile() && item.name === 'index.ts') {
                const outputName = relative(rootDir, dirname(fullPath)).replace(/\\/g, '/');
                entries[outputName] = fullPath;
            }
        }
    }

    walk(rootDir);
    return entries;
}

export default defineConfig({
    build: {
        outDir: 'wwwroot/js',
        emptyOutDir: false,
        rollupOptions: {
            input: collectEntries('ClientApp'),
            output: {
                entryFileNames: '[name].js'
            }
        }
    }
});

The type of rollupOptions.input is Record<string, string>.

  • Key: Output JS filename (relative path) ➜ becomes [name] in output
  • Value: Absolute path to the corresponding TypeScript file

collectEntries recursively traverses the ClientApp folder. When it finds an index.ts, it sets the folder name as the js filename. This way, if you create index.ts files within the ClientApp folder, js files will be created with filenames reflecting the folder structure.

Running the Vite development server

So far, we've looked at how to use Vite as a build tool. Next, we'll examine how to use it as a development server. For ASP.NET Core + Visual Studio, IIS Express or Kestrel typically serve as the development web server. We will keep these as they are and use Vite as a server for js / ts files.

1Add npm scripts

Add npm scripts to package.json.

{
  "scripts": {
    "dev": "vite"
  },
  "devDependencies": {
    "typescript": "^5.9.3",
    "vite": "^7.1.7"
  }
}

2Startup

Let's run it.

npm run dev

If you see the following, the Vite development server has started successfully:

  VITE v7.1.7  ready in 196 ms

  ➜  Local:   http://localhost:5173/
  ➜  Network: use --host to expose
  ➜  press h + enter to show help

The Vite server uses port number 5173.

3Verify operation

Let's check the operation.
Create the following TypeScript file:

// ClientApp/pages/hoge/index.ts
console.log('hoge');

The cshtml should be written as follows:

<script type="module" src="http://localhost:5173/@@vite/client"></script>
<script type="module" src="http://localhost:5173/ClientApp/pages/hoge/index.ts"></script>

Run the project and display the page. If hoge is displayed in the browser console, it's working correctly.

Next, let's change the TypeScript code:

// ClientApp/pages/hoge/index.ts
console.log('hoge');
alert('fuga');

The alert message fuga appeared without needing to reload the page. This is HMR (Hot Module Replacement).

4Root settings

The reference to the ts file in the cshtml file was written as follows:

<script type="module" src="http://localhost:5173/@@vite/client"></script>
<script type="module" src="http://localhost:5173/ClientApp/pages/hoge/index.ts"></script>

Unless otherwise specified, Vite uses relative paths based on the project's root folder.
You can specify the base folder using root.

// vite.config.ts
・・・
export default defineConfig({
    root: 'ClientApp',
    build: {
        outDir: '../wwwroot/js',
        emptyOutDir: false,
        rollupOptions: {
            input: collectEntries('ClientApp'),
            output: {
                entryFileNames: '[name].js'
            }
        }
    }
});

This way, you can write it a little more compactly:

<script type="module" src="http://localhost:5173/@@vite/client"></script>
<script type="module" src="http://localhost:5173/pages/hoge/index.ts"></script>

Note that @vite/client is the client module that enables HMR. (The @@ with two @ signs is for escaping because @ is a special character in Razor.)

5Automatically start the Vite development server

We start the Vite development server with npm run dev, but it's tedious to enter the command every time. We'll configure it to automatically start the Vite development server when the web application starts. First, terminate the running Vite development server with Ctrl + C.

Add the following code to Program.cs:

// Program.cs
var app = builder.Build();

// Start Vite development server (development environment only)
if (app.Environment.IsDevelopment())
{
    // Check if port 5173 is in use
    var listeners = IPGlobalProperties.GetIPGlobalProperties().GetActiveTcpListeners();
    var is5173PortInUse = listeners.Any(ep => ep.Port == 5173);

    // If port 5173 is not in use, start the Vite server
    if (!is5173PortInUse)
    {
        var viteDevServer = new ProcessStartInfo
        {
            FileName = @"C:\Program Files\nodejs\npm.cmd",
            Arguments = "run dev",
            WorkingDirectory = Directory.GetCurrentDirectory(),
            UseShellExecute = true,
        };
        var viteProcess = Process.Start(viteDevServer);

        // Kill Vite on exit
        app.Lifetime.ApplicationStopping.Register(() =>
        {
            if (viteProcess != null && !viteProcess.HasExited)
            {
                viteProcess.Kill(true);
            }
        });
    }
}

Let's run the web application.
The Vite development server has started in a command prompt window (Figure 1). Figure 1.Figure 1.

Referencing built files in cshtml

In a development environment, you reference ts files, but in a production environment, you will reference built and bundled js files.

How should this be written in the cshtml file?

@inject IWebHostEnvironment Env
@if (Env.IsDevelopment())
{
    // Development environment: Reference ts files via Vite development server
	<script type="module" src="http://localhost:5173/@@vite/client"></script>
    <script type="module" src="http://localhost:5173/pages/hoge/index.ts"></script>
}
else
{
    // Production environment: Reference built js files
    <script type="module" src="~/js/pages/hoge.js" asp-append-version="true"></script>
}

This way, we branch using the IsDevelopment extension method.
We don't want to write this code in every cshtml file, so let's create a tag helper.

using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.AspNetCore.Razor.TagHelpers;
using Microsoft.Extensions.Hosting;

namespace korochin.Core.Web.TagHelpers;

[HtmlTargetElement("vite-script", Attributes = "entry")]
public class ViteScriptTagHelper(IWebHostEnvironment env, IFileVersionProvider fileVersionProvider) : TagHelper
{
    [ViewContext]
    [HtmlAttributeNotBound]
    public required ViewContext ViewContext { get; set; }

    [HtmlAttributeName("entry")]
    public string Entry { get; set; } = "";

    public override void Process(TagHelperContext context, TagHelperOutput output)
    {
        output.TagName = null;

        if (env.IsDevelopment())
        {
            // Development environment: Reference ts files via Vite development server
            output.Content.SetHtmlContent($@"
<script type=""module"" src=""http://localhost:5173/@@vite/client""></script>
<script type=""module"" src=""http://localhost:5173/{Entry}/index.ts""></script>");
        }
        else
        {
            // Production environment: Reference built js files
            var jsPath = $"/js/{Entry}.js";
            var versionedPath = fileVersionProvider.AddFileVersionToPath(ViewContext.HttpContext.Request.PathBase, jsPath);
            output.Content.SetHtmlContent($@"<script src=""{versionedPath}""></script>");
        }
    }
}

Add to _ViewImports.cshtml to register the tag helper.

//_ViewImports.cshtml
@using WebApplication1
@namespace WebApplication1.Pages
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@addTagHelper *, WebApplication1

The cshtml file can be written as follows, making it compact.

<vite-script entry="pages/hoge" />

Integration into CI/CD

Here's how to integrate Vite's build process into your CI/CD pipeline.
This section describes how to add Vite's build process to a deployment using Azure Pipelines.

1package.json

Add an npm script to package.json so that npm run build executes vite build.

{
  "scripts": {
    "dev": "vite",
    "build": "vite build"
  },
  "devDependencies": {
    "typescript": "^5.9.3",
    "vite": "^7.1.7"
  }
}

2azure-pipelines.yml

Add the following processes to azure-pipelines.yml:

  • Install Node.js
  • Install dependencies
  • Execute npm run build

Since Vite's settings are based on the web application's project folder, you need to set the workingDirectory to the web application's project folder. The following example assumes the web application's folder name is "korochin.Hoge".

package.json

trigger:
- main

pool:
  vmImage: ubuntu-latest

steps:
# Install Node.js
- task: NodeTool@0
  inputs:
    versionSpec: '22.x'
  displayName: 'Install Node.js'

# Install dependencies
- script: |
    npm ci
  displayName: 'Install dependencies'
  workingDirectory: korochin.Hoge

# Execute npm run build
- script: |
    npm run build
  displayName: 'npm run build'
  workingDirectory: korochin.Hoge

- task: DotNetCoreCLI@2
  inputs:
    command: 'publish'
    publishWebProjects: true
    arguments: '--configuration Release'

- task: AzureWebApp@1
  inputs:
    azureSubscription: 'Service Connection'
    appType: 'webAppLinux'
    appName: 'korochin-hoge'
    package: '$(System.DefaultWorkingDirectory)/**/*.zip'

With this, ts files will be automatically compiled when pushed to the repository.

Footnotes

  1. JavaScript and TypeScript in Visual Studio

    The TypeScript SDK is deprecated in Visual Studio 2022. Existing projects that rely on the SDK must be upgraded to use the NuGet package.

  2. For example, Bundler & Minifier allows you to bundle multiple compiled js files in arbitrary units.