The web development community has come a long way since the early days of the web. Building interactive web experiences can leave many developers in a state of paralysis. What web framework should we use? What transpiler should create my assets? Do I go with React or VueJS? So many questions that we need to answer. For folks who work on the back end of the technological stack, or for people who only focus on HTML and CSS, rejoice!

In this post, we’ll be exploring Turbolinks. An approach to building client-side experiences with little to no client-side code.

Turbolinks is a JavaScript library that comes out of the Ruby on Rails community. The project claims that Turbolinks® makes navigating your web application faster. How exactly does it do that?

When you follow a link, Turbolinks automatically fetches the page, swaps in its <body>, and merges its <head>, all without incurring the cost of a full-page load. –Turbolinks

Developers may be used to modern client-side frameworks, where we write all behavior using components. To those developers, the Turbolinks approach may seem like a departure or even archaic. A full-page render on the server? Why not just JSON or another serializable format?.

Well, there are a few advantages to using Turbolinks:

  • Turbolinks optimizes navigation automatically by treating all links as avisit. Turbolinks can also take advantage of cache headers and other HTTP features.
  • Little server-side cooperation is necessary. Respond with full HTML pages, and Turbolinks does the merging for you on the client.
  • Respects the web, which means back and reload buttons work as expected. Additionally, search engines can crawl the site for maximum SEO juice as the site will fallback gracefully.
  • Mobile app support with adapters for iOS and Android allows developers to use Turbolinks in a way that integrates with a mobile operating system’s navigation controls.

Read more about Turbolinks at the official GitHub repository. The documentation is brief and a breeze to read through. In the next section, we’ll see how we can convert an ASP.NET 5 application to take advantage of Turbolinks.

There is a sample repository with a working sample of ASP.NET and Turbolinks located at this GitHub repository. This post will omit some code for the sake of brevity.

In general, to use Turbolinks with ASP.NET, we only need to add a link to the JavaScript library in the <head> tag.

<head>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/turbolinks/5.2.0/turbolinks.js" integrity="sha512-G3jAqT2eM4MMkLMyQR5YBhvN5/Da3IG6kqgYqU9zlIH4+2a+GuMdLb5Kpxy6ItMdCfgaKlo2XFhI0dHtMJjoRw==" crossorigin="anonymous"></script>
</head>

It’s important that we put all JavaScript links in the <head> tag. Remember that each page will swap the head and body, so keeping the head as static as possible will reduce network calls for resources.

This may be counter-intuitive to some who may have learned to put JavaScript tags at the end of the body for performance reasons.

The most significant change for our server-side applications is adding a Turbolinks-Location header to our responses. Turbolinks read the header and allows the client-side to follow server redirects. The header is necessary since JavaScript cannot access 301 Permanent Redirect or 302 Temporary Redirect status code responses.

However, Turbolinks makes requests using XMLHttpRequest, which transparently follows redirects. There’s no way for Turbolinks to tell whether a request resulted in a redirect without additional cooperation from the server. –Turbolinks

To create the middleware is straight forward. We need to set the Turbolinks-Location on our server responses.

public class TurbolinksMiddleware : IMiddleware
{
    public const string TurbolinksLocationHeader 
        = "Turbolinks-Location";

    public async Task InvokeAsync(
        HttpContext httpContext, 
        RequestDelegate next
    )
    {
        httpContext.Response.OnStarting((state) => {
            if (state is HttpContext ctx) 
            {
                if (ctx.IsTurbolinksRequest())
                {
                    ctx.Response.Headers.Add(TurbolinksLocationHeader, ctx.Request.GetEncodedUrl());
                }
            }

            return Task.CompletedTask;
        }, httpContext);
        
        await next(httpContext);
    }
}

Knowing a request was made via Turbolinks is determined by the presence of a Turbolinks-Referrer request header.

public static bool IsTurbolinksRequest(this HttpContext ctx) =>
    ctx.Request.Headers.ContainsKey("Turbolinks-Referrer");

For most applications, this would be more than enough to take advantage of Turbolinks, but there is still one more feature we need to implement. We will need to register the middleware with both the IServiceCollection instance and the IApplicationBuilder instance in our Startup file using extension methods.

public static IApplicationBuilder 
    UseTurbolinks(this IApplicationBuilder app) => 
    app.UseMiddleware<TurbolinksMiddleware>();

public static IServiceCollection
    AddTurbolinks(this IServiceCollection serviceCollection) =>
    serviceCollection.AddSingleton<TurbolinksMiddleware>();

Here are parts of the Startup file.

public void ConfigureServices(IServiceCollection services)
{
    services.AddRazorPages();
    services.AddTurbolinks();
}

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    if (env.IsDevelopment())
    {
        app.UseDeveloperExceptionPage();
    }
    else
    {
        app.UseExceptionHandler("/Error");
        // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
        app.UseHsts();
    }

    app.UseHttpsRedirection();
    app.UseStaticFiles();    
    app.UseRouting();
    app.UseAuthorization();

    app.UseTurbolinks();

    app.UseEndpoints(endpoints =>
    {
        endpoints.MapRazorPages();
        endpoints.MapControllers();
    });
}

By default, Turbolinks expects that each web request will have a response of a full HTML page. Responding to a form POST with HTML may be acceptable to some, but for others, it may not be. That’s what brings us to our next necessary enhancement to ASP.NET, the TurbolinksRedirectResult.

Conventional approaches to handling form data follow a POST-GET-Redirect pattern. The method allows us to avoid the dreaded form resubmit confirmation dialog, which you can read about here. In the case of ASP.NET, we can implement an IActionResult, which is both useable from ASP.NET MVC and ASP.NET Razor Pages.

public class TurbolinksRedirectResult : RedirectResult
{
    public TurbolinksActions TurbolinksAction { get; }

    public TurbolinksRedirectResult(string url, TurbolinksActions turbolinksAction = TurbolinksActions.Active) 
        : base(url)
    {
        TurbolinksAction = turbolinksAction;
    }

    public override Task ExecuteResultAsync(ActionContext context)
    {
        var httpContext = context.HttpContext;
        if (httpContext.IsXhrRequest())
        {
            
            var action = TurbolinksAction.ToString().ToLower();
            var content = httpContext.Request.Method == HttpMethods.Get
                ? $"Turbolinks.visit('{this.Url}');"
                : $"Turbolinks.clearCache();\nTurbolinks.visit('{this.Url}', {{ action: \"{ action }\" }});";
            

            var contentResult = new ContentResult {
                Content = content,
                ContentType = "text/javascript"
            };
            
            var executor = context
                .HttpContext
                .RequestServices
                .GetRequiredService<IActionResultExecutor<ContentResult>>();
            
            return executor.ExecuteAsync(context, contentResult);
        }
        else
        {
            return base.ExecuteResultAsync(context);                
        }
    }
}

public enum TurbolinksActions
{    
    Active,
    Replace
}

We can also write a few extension methods to make using the new TurbolinksRedirectResult more natural.

For the first example, let’s look at the implementation inside a Razor Page, and note the use of TurbolinksRedirectToPage.

public class IndexModel : PageModel
{
    private readonly ILogger<IndexModel> _logger;

    public IndexModel(ILogger<IndexModel> logger)
    {
        _logger = logger;
    }

    public void OnGet()
    {
    }

    public bool HasName => !string.IsNullOrWhiteSpace(Name);
    public string Name { get; set; }

    public IActionResult OnPost()
    {
        return this.TurbolinksRedirectToPage("Privacy");
    }
}

We can also use the same TurbolinkRedirectResult inside of an MVC controller.

[Route("[controller]")]
public class ValuesController : Controller
{
    // Post
    [HttpPost, Route("")]
    public IActionResult Index()
    {
        return this.TurbolinksRedirectToPage("/Privacy");
    }
}

The sample project has extension methods for both ASP.NET MVC and Razor Pages, which allows us to redirect between the two approaches.

For the redirect to work correctly, we need to hijack form posts from the client and make those requests via XHR.

<script type="text/javascript">
 $(function () {
     
    $("#form").submit(function(e) {
        var action = $(this).attr("action");
        var data = $(this).serialize();
        var callback = function(data) {
            console.log(data);
        };
        
        $.post(action, data, callback);                 
        e.preventDefault();
    });
  });
</script>   

Using an XMLHttpRequest allows Turbolinks to intercept our redirect response, which is at most two lines of JavaScript.

Turbolinks.clearCache();
Turbolinks.visit('/Privacy', { action: "active" });

Our server’s response tells Turbolinks to clear the cache for all visits and then navigate to the Privacy page. It’s pretty neat!

A reader by the name of kpax pointed out an issue with the jQuery implementation I had used in this post. Every time the hijacked form would post to the server, we would get an error in the developer tool console in Chrome, Safari, or Edge. Even stranger, Turbolinks would navigate as intended, so while we would see the following error, it wasn’t entirely catastrophic.

this.controller[e] is not a function

The error might also manifest as the following exception, depending on how Turbolinks was installed.

visit.ts:96 Uncaught TypeError: Cannot read property 'call' of undefined
    at Visit.changeHistory (visit.ts:96)
    at BrowserAdapter.visitStarted (browser_adapter.ts:27)
    at Visit.start (visit.ts:61)
    at Controller.startVisit (controller.ts:273)
    at Controller.startVisitToLocationWithAction (controller.ts:86)
    at BrowserAdapter.visitProposedToLocationWithAction (browser_adapter.ts:22)
    at Controller.visit (controller.ts:76)
    at Object.visit (namespace.ts:17)
    at <anonymous>:2:12
    at m (jquery.min.js:2)

For some strange reason, that I am unable to diagnose, JQuery 3+ and Turbolinks don’t seem to work nicely together. Downgrading to JQuery 2+ or even using Zepto seems to resolve the issue. Also, if you are using either JQuery or Zepto, be sure to use event delegation to keep your web application’s memory footprint low. Using event delegation also allows us to load our site’s script file once and have our JavaScript work after each page visit.

Conclusion

In a world where it becomes more complex to build even the most straightforward experiences, its pleasant to see folks trying to deliver on simplicity. Turbolinks is an approach all developers should consider when making their next web application. The library also has the advantage of being server stack agnostic, meaning developers from many communities can benefit from using it. We were able to integrate Turbolinks with a few classes and have the library’s full power in ASP.NET.

If you haven’t already, please try out the sample project and let me know what you think of Turbolinks.

Special Thanks

I want to thank the F# community for their implementation of a TurbolinksMiddleware found in Saturn, an F# web framework. If you’re working with F# and interested in Turbolinks, I suggest trying out the Saturn Framework.