The release of .NET 6 unleashed a wave of ASP.NET Core goodies on the development community. Some features received much attention, while some flew silently under the radar. One of the features I recently discovered was CSS isolation and JavaScript files collocated with a component, MVC view, or Razor Page. Unfortunately, while the CSS isolation feature works impressively well, the JavaScript collocation feature feels incomplete. With the CSS isolation feature, you can add a .css file alongside your ASP.NET Core component, and ASP.NET Core will process your styles into a specific CSS file. However, you still need to reference each collocated file in your views with JavaScript files.

I think we can do better. We’ll explore writing a TagHelper that just works in this post. We can import collocated JavaScript files using a single tag helper with my solution depending on whether the file exists or not. Let’s get started!

Collocated JavaScript Files In JavaScript

ASP.NET Core allows us to store JavaScript files alongside Razor views and Razor components. The feature improves our ability to reason about our apps by keeping all elements as local as possible. Here are some examples pulled from the Microsoft documentation site:

Collocate JS files using the following filename extension conventions:

  • Pages of Razor Pages apps and views of MVC apps: .cshtml.js. Examples:
    • Pages/Index.cshtml.js for the Index page of a Razor Pages app at Pages/Index.cshtml.
    • Views/Home/Index.cshtml.js for the Index view of an MVC app at Views/Home/Index.cshtml.
  • Razor components of Blazor apps: .razor.js. Example: Pages/Index.razor.js for the Index component at Pages/Index.razor.

In summary, all JavaScript files collocated with their Razor counterparts are accessible from the client. The downside is you need to remember to reference each file in your parent Razor component or view.

@section Scripts { <script src="~/Pages/Index.cshtml.js"></script> }

Using sections also has the added burden of managing them on your _Layout.cshtml. Ugh, if only there were a better way?!

View Script Tag Helper adds Collocated Scripts Automatically

With ASP.NET Core, we can build some beneficial tag helpers. In our case, let’s look at the final result.

<script src="~/lib/jquery/dist/jquery.min.js"></script>
<script src="~/lib/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
<script src="~/js/site.js" asp-append-version="true"></script>
<view-script append-version="true" />

Note that we have the view-script element in the preceding code, with an attribute of append-version. This tag helper will first find what view ASP.NET Core is attempting to render and try and determine if a collocated JavaScript file exists. If it does, it will generate a script tag to the page; otherwise, we will get no output. Another interesting part of the following code is, files may be located in different locations based on whether the application is in development or has been published. This means that we have to look at both the content root and the web root for our JavaScript files. Finally, I added the append-version feature to match the existing ASP.NET Core ScriptTagHelper implementation.

Let’s take a look at the ViewScriptTagHelper.

using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.AspNetCore.Razor.TagHelpers;
using Microsoft.Extensions.FileProviders;

namespace Isolated;

public class ViewScriptTagHelper : TagHelper
    private readonly IWebHostEnvironment environment;
    private readonly IFileVersionProvider fileVersionProvider;
    private const string AppendVersionAttributeName = "append-version";

    public ViewScriptTagHelper(IWebHostEnvironment environment, IFileVersionProvider fileVersionProvider)
        this.environment = environment;
        this.fileVersionProvider = fileVersionProvider;

    public ViewContext? ViewContext { get; set; }
    /// <summary>
    /// Value indicating if file version should be appended to src urls.
    /// </summary>
    /// <remarks>
    /// A query string "v" with the encoded content of the file is added.
    /// </remarks>
    public bool? AppendVersion { get; set; }

    public override void Process(TagHelperContext context, TagHelperOutput output)
        // remove the `page-script` tag if script doesn't exist
        output.TagName = null;
        output.TagMode = TagMode.StartTagAndEndTag;

        var viewPath = ViewContext?.View.Path;
        var src = $"{viewPath}.js";
        /* When the app is published, the framework automatically moves the script to the web root.
           So we should check both places, with the content root first for development */
        var fileInfo = environment.ContentRootFileProvider.GetFileInfo(src) ?? 

        if (fileInfo is {Exists: true})
            // switch it to script now
            output.TagName = "script";
            output.Content = new DefaultTagHelperContent();
            if (AppendVersion == true)
                // people love their cache busting versions
                src = fileVersionProvider.AddFileVersionToPath(src, src);
            output.Attributes.Add("src", src);

Adding the ViewScriptTagHelper to your project will give you this new functionality. Be mindful of registering the tag helper in your _ViewImports.cshtml file.

@using Isolated
@namespace Isolated.Pages
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@addTagHelper *, Isolated

In my case, the name of my project and namespace is Isolated. Be sure to update the code to match your project and namespaces.

I’ve tested this tag helper with ASP.NET Core MVC and Razor Pages, and it works in both instances. I have not experimented with Razor or Blazor components, but I suspect it will work.


Tag helpers are immensely powerful, and the additions of CSS isolation and JavaScript collocation make building components in ASP.NET Core applications can make building web apps a joy. With the tag helper in this post, you’ll have one less moving element to worry about during the development process. I hope you enjoyed this post. Please share and remember to follow me on Twitter @buhakmeh. And as always, thank you for reading.