ASP.NET Core + Vite
Table of Contents
-
- 3.1. Install Vite
- 3.2. Install TypeScript
- 3.3. Create vite.config.ts
- 3.4. Specify the output folder
- 3.5. Specify the output filename
- 3.6. Specify the ts file folder
- 3.7. Support multiple file outputs
- 3.8. Target all index.ts files
-
- 4.1. Add npm scripts
- 4.2. Startup
- 4.3. Verify operation
- 4.4. Root settings
- 4.5. Automatically start the Vite development server
-
- 6.1. package.json
- 6.2. azure-pipelines.yml
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.tsfiles within theClientAppfolder are entry points. - Output
jsfiles 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]inoutput - 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.
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
-
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.
-
For example, Bundler & Minifier allows you to bundle multiple compiled
jsfiles in arbitrary units.↩