Cover

Vite-Powered SPAs in ASP.NET Core Projects

January 18, 2023
No Comments.

If you’ve heard me talk about Vite in the past (and so commonly mispronouce it), you know I am a fan. With many Vue, React and SvelteKit applications are moving to Vite, I’ve been investigating how to integrate it for development and production into ASP.NET Core applications. Let’s see what I found out.

I also made a Coding Short video that covers this same topic, if you’d rather watch than read:

Short Intro to Vite

Normally, we’ve used packagers (Webpack, Rollup) to at development-time to watch for changes and hot-swap or reload pages as necessary. For development time, approaches this differently. While Vite also does hot-swapping of code, but it approaches this with actually compiling the project. Instead it exposes a server for a project that relies on script modules.

For example, to start a project, you need to just point at the entry file:

<body>
  <div id="app"></div>
  <script type="module" src="/src/main.js"></script>
</body>

Most modern browsers now support the script-type of module. In this case, Vite loads the main.js and then just follows imports and exports to load all the parts of the project that you need. This means that startup is incredibly fast since there really isn’t any compilation step.

When you’re developing directly with Vite, you can just start it at the command-line and it will server the index.html as well as the script/resource files. Though Vite isn’t really a production ready web-server. The serving of the files is really to have a great development-time experience.

For production time, it still compiles projects (by default with Rollup) in the same way that these frameworks have always done.

With this different approach, integrating with ASP.NET Core presents some challenges.

Integrating a Vite project for Production

A little background: our project is a simple ASP.NET Core project with a Vite project as a subdirectory called “Client”:

Figure 1

Before we talk about how to get Vite working for development, let’s talk about how it will work when you publish your app for production (or other non-development builds).

In a Vite project, you can use Vite to build your project by using the build command (shown here in a package.json file’s scripts):

  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },

Vite uses Rollup (by default) to build and package your project for production. So we can configure Vite to output our project by modifying the vite.config.js file and adding a build configuration:

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [vue()],
  build: {
    outDir: "../wwwroot/client",
    emptyOutDir: true,
  },
  resolve: {
    alias: {
      "@": fileURLToPath(new URL("./src", import.meta.url)),
    },
  },
});

The outDir is pointing at our wwwroot folder where the ASP.NET Core project will have access to the build files. The emptyOutDir is specifically to empty it before building so you don’t get any extra assets littering that folder.

When we build the project, we get several files generated:

vite v4.0.4 building for production...
✓ 31 modules transformed.
../wwwroot/client/index.html                  0.45 kB
../wwwroot/client/assets/index-ca646b5b.css   1.22 kB │ gzip:  0.48 kB
../wwwroot/client/assets/index-bdf9da80.js   77.78 kB │ gzip: 30.96 kB

Then we can just reference these files in the host page (a Razor page in this example):

@page @section Styles {
<link rel="stylesheet" href="/client/assets/index-ca646b5b.css" />
} @section Scripts {
<script src="/client/assets/index-bdf9da80.js"></script>
}
<h1>Film List</h1>
<div id="app"></div>

Notice that we’re also adding any markup (the div#app in this example) that the Vite project needs.

But we have a problem, the Vite build is giving a cache-busting name (the random string after index-). On every build, this will change, so we can use a special tag helpers:

@section Styles {
<link rel="stylesheet" asp-href-include="/client/assets/index-*.css" />
} @section Scripts {
<script asp-src-include="/client/assets/index-*.js"></script>
}

By using the asp-href-include and asp-src-include tag helpers, we can use a wildcard to include the right files for us.

Lastly, we need to actually run the build. We can do this by just adding the build to the .csproj file. By adding a Target for before publish, we can just execute the build:

<Target Name='CompileClient'
        BeforeTargets="Publish">
  <Exec WorkingDirectory="./client"
        Command="npm install" />
  <Exec WorkingDirectory="./client"
        Command="npm run build" />
</Target>

Notice that we’re calling npm install first to be sure that all the packages exist. And that we’re using the WorkingDirectory to specify our client directory.

Now, when you publish the project (manually or in a build script), the Vite project is built too!

But we came to talk about development, let’s talk about that next.

Integrating Vite for Development

Like we saw earlier, during development, you would run Vite and it would load scripts on demand using the type=module method. When you run Vite in this mode, it is essentially running a server for the markup and a single, large SPA. If you’re creating APIs with ASP.NET Core and just hosting your SPA as a single HTML file, this works perfectly.

NOTE: There is a package called Microsoft.AspNetCore.SpaServices.Extension that is meant to do this, but it is not well documented and may be depreciated by now. It didn’t work well with Vite, though it might for Angular and React using their CLIs

But in many cases, you’ll want to host one or more SPAs on specific pages of your project. How do we handle this since both ASP.NET Core and Vite will be serving files?

During development you’ll want to run both servers, and just use the vite serving for the assets (.js/.css) for your project. To do this, let’s look at the Razor page again:

@page @section Styles {
<link rel="stylesheet" asp-href-include="/client/assets/index-*.css" />
} @section Scripts {
<script asp-src-include="/client/assets/index-*.js"></script>
}
<h1>Film List</h1>
<div id="app"></div>

What we want to do here is only use these styles and script tags during production, so we can surround it with an environment tag for production:

<environment include="Production">
  @section Styles {
  <link rel="stylesheet" asp-href-include="/client/assets/index-*.css" />
  } @section Scripts {
  <script asp-src-include="/client/assets/index-*.js"></script>
  }
</environment>

This will set it up to only use the build assets during production. We can then add an environment tag for development:

<environment include="Development">
  <script type="module" src="http://localhost:5000/src/main.js"></script>
</environment>

You’ll notice that we’re using the Vite server to serve the main.js file. If you remember from earlier, this will load other assets on-demand and hot-swap them as necessary.

In this way you get the best of both worlds. But we have a problem:

Dealing with Routing in Vite Projects

In our example, we’re hosting the SPA on a page who’s URL is http://localhost:8000/FilmList. This is related to the Razor page’s URL. But our Vite project (Vue in this case) is using history-type routing. That means, when it navigates, it takes over the URL. So when we navigate to our SPA’s home page, it changes it to http://localhost:8000/ and for the list page it changes the URL to http://localhost:8000/films (which are based on the projects own routing, not server-side routing).

The problem is if we refresh the page or open that URL, it fails because we’re not serving up our Razor page at those URLs. There are two fixes here. First, we need to tell the Vite project what the base address for our project is. We can do this in vite.config.js:

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [vue()],
  base: "/FilmList",
  build: {
    outDir: "../wwwroot/client",
    emptyOutDir: true,
  },
  resolve: {
    alias: {
      "@": fileURLToPath(new URL("./src", import.meta.url)),
    },
  },
});

You can see the addition of the base property which let’s the app know what the base URL for the project is. In this way, the navigation will start at that base URL. This is better. But then our SPA’s route for the list of films is http://localhost:8080/FilmList/films. But this isn’t a valid server-route either. We need a way to have inter-SPA URLs serve the SPA page (and the internal routing do the right thing).

To do this, you can simply use fallback routes in the ASP.NET Core server. You’ll want to make sure that any fallbacks are specified after all your other routes (e.g. Razor Pages, Controllers and Minimal APIs). You to this by using the MapFallback calls. For example, in our case (since we’re using Razor pages) we can use MapFallbackToPage like so:

app.MapGet("api/films", async (BechdelDataService ds, int? page, int? pageSize) =>
{
  //...
}).Produces<IEnumerable<Film>>(contentType: "application/json").Produces(404).ProducesProblem(500);

app.MapFallbackToPage("/FilmList");

app.Run();

Note, that this fallback is not redirecting, but just serving that page. That way the URL is preserved for the Vite project to use for it’s own routing.

This will fallback to any page that isn’t found in routing to the FilmList Razor page that contains our SPA. That might be too broad though, you may want to use the fallback with a pattern too (so it only falls back to that page’s URLs) like so:

app.MapFallbackToPage("/FilmList/{*path}", "/FilmList");

The first parameter of the MapFallbackToPage allows you to specify a routing pattern to apply this fallback to. In this way, any urls that start with /FilmList will just fallback to that page.

I hope this helps some of you using Vite for your own projects. You can get the example for this project at:

https://github.com/shawnwildermuth/codingshorts/tree/main/aspnetvite

I’m happy to answer any of your questions below if I’ve been unclear about any of this!

Thanks for reading.