loading...

Kentico 12: Design Patterns Part 6 - Rendering Meta Tags in Kentico 12 MVC

seangwright profile image Sean G. Wright Updated on ใƒป7 min read

Photo by Sai Kiran Anagani on Unsplash

The Importance of Meta Tags

HTML Meta tags don't display information to our visitors - they instead display information to other systems or web crawlers - helpful hints to indicate information about the site and the content on a page.

If we want to curate the content that shows up in search engine results for our site, then we need to use meta tags to provide the best description possible.

If we want our site to be easily and accurately shared through social media, we need to add the correct meta tags to the page to tell those social platforms (ex: Facebook, Pinterest, and Twitter) what is being shared.

Ok, so how do we ensure these meta tags are correctly applied to the pages rendered by our Kentico 12 MVC applications? ๐Ÿค”

The Simple Approach

Our <meta> tags need to be added in the <head> of our HTML document. In ASP.NET MVC the <head> is usually located in the Views\_Layout.cshtml file.

The DancingGoat sample site presents us with something like this:

<!DOCTYPE html>

<html>
<head id="head">
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta charset="UTF-8" />
    <title>@ViewBag.Title - Dancing Goat</title>
    <link rel="icon" href="@Url.Content("~/content/images/favicon.png")" type="image/png" />
    <link href="@Url.Content("~/Content/Styles/Site.css")" rel="stylesheet" type="text/css" />
    @RenderSection("styles", required: false)

    <!-- We can add some meta tags -->
    <meta name="description" content="A description of the page!" />
</head>

This file is effectively the "master page" of the site with all page specific content being rendered in a section within it.

We want to add <meta> tags to the <head> above, but we have a problem.

The information we want to put into the <meta> tags comes from each specific page, but this layout is used by all pages. ๐Ÿคจ

We need to set this information dynamically, per request.

MVC presents us with a solution in the form of a ViewBag.

The ViewBag is a dynamic (read: un-typed) collection of data that we can access in our Views and which can be assigned values anywhere in the MVC pipeline.

It's globally available in all of our Views, including the _Layout.cshtml.

So, the standard approach would be to assign some values to the ViewBag in a Controller class and then access the data in the layout:

// ProductsController.cs

public class ProductController
{
    public ActionResult Index()
    {
         ViewBag.OGTitle = "Acme Products ๐ŸŽ‚";
         ViewBag.OGImage = "https://acme-products.com/logo.png";
         ViewBag.Description = "A description of the page!";

         return View();
    }
}
<!-- _Layout.cshtml -->

<head>
   <!-- ... -->
   <meta name="og:title" content="@ViewBag.OGTitle" />
   <meta name="og:image" content="@ViewBag.OGImage" />
   <meta name="description" = content="@ViewBag.Description" />
</head>

This works, but there's a few issues...

  • โŒ We end up performing the same kind of assignments, throughout the code, that we have in our example controller - except there's no validation on the data or requirements that all the required ViewBag keys are assigned
  • โŒ The ViewBag is dynamic, which means you can typo a property name and not have any of the compiler support we normally get from C#
  • โŒ ViewBag use is usually discouraged in applications that scale beyond a demo, and having this ViewBag use here might encourage more of it, with the caveats above multiplied.
  • โŒ ViewBag isn't really unit testable... soo... ๐Ÿ˜ฃ

We have the power of C# and MVC at our fingertips, let's use them to solve this dilemma!

Design Patterns To The Rescue

We can use several common software design patterns to help us come to a more elegant and robust solution. ๐Ÿ˜Ž

Domain Driven Design (DDD) - Protecting Invariants

First, let's define a type to represent the "meta" information we want in our page.

public class SiteMeta
{
    public const string OGImage = "og:image";
    public const string OGSiteName = "og:site_name";

    private readonly Dictionary<string, string> metas;

    public string Title { get; } = "";

    public IReadOnlyDictionary<string, string> Metas => metas;

    public SiteMeta(
        string title,
        Dictionary<string, string> metas = null)
    {
        Guard.Against.NullOrWhiteSpace(title, nameof(title));

        Title = title;

        this.metas = metas is null
            ? new Dictionary<string, string>()
            : metas;
    }

    public void SetOGImage(string value)
    {
        Guard.Against.NullOrWhiteSpace(value, nameof(value)); 

        metas[OGImage] = value;       
    }

    public void AddMeta(string key, string value)
    {
        Guard.Against.NullOrWhiteSpace(key, nameof(key));
        Guard.Against.NullOrWhiteSpace(value, nameof(value));

        metas[key] = value;
    }
}

We have defined a class that protects its invariants by using Guard Clauses for both the constructor and method calls. ๐Ÿ‘

We also add some strong typing with SetOGImage() that solves the issue we had with the dynamic ViewBag. ๐Ÿ˜„

This is a good first step towards ensuring our application works how we expect (and it's also very unit testable code!)

Dependency Injection (DI) - Composing Layers

Now let's create a class which acts as a store for the SiteMeta type, allowing one part of the application to set the value and another to retrieve it.

public interface ISiteMetaService
{
    SiteMeta Get();
    void Set(SiteMeta siteMeta);
}

public class SiteMetaService : ISiteMetaService
{
    private SiteMeta currentSiteMeta;

    public SiteMeta Get() => currentSiteMeta ?? new SiteMeta("Default");

    public void Set(SiteMeta siteMeta)
    {
        Guard.Against.Null(siteMeta, nameof(siteMeta));

        currentSiteMeta = siteMeta;
    }
}

We can register this type with our Inversion of Control (IoC) container and then supply it as a constructor parameter where we need to use it - like in a Controller (injecting our dependency).

We want our ISiteMetaService to have a lifetime of "per-request" in our IoC container. This helps ensure that SiteMeta from a previous request isn't accidentally used on a later request.

public class ProductController
{
    private readonly ISiteMetaService service;

    public ProductController(ISiteMetaService service)
    {
         Guard.Against.Null(service, nameof(service));

         this.service = service;
    }

    public ActionResult Index()
    {
         // These values could also come from a custom Kentico PageType

         var meta = new SiteMeta("Acme Products ๐ŸŽ‚");

         meta.SetOGImage("https://acme-products.com/logo.png");

         service.Set(meta);

         return View();
    }
}

Now we can access this service through DI - property injection instead of constructor injection - in an ActionFilterAttribute:

public class SiteMetaActionFilterAttribute : ActionFilterAttribute
{
    public ISiteMetaService SiteMetaService { get; set; }

    public override void OnActionExecuted(ActionExecutedContext filterContext)
    {
        filterContext.Controller.ViewBag.SiteMeta = SiteMetaService.Get();

        base.OnActionExecuted(filterContext);
    }
}

In our _Layout.cshtml we are guaranteed to have a SiteMeta property on the ViewBag of type SiteMeta:

<head>
   @{
       var siteMeta = ViewBag.SiteMeta as SiteMeta;
   }
   <title>@siteMeta.Title</title>

   @foreach(var meta in siteMeta.Metas)
   {
       <meta name="@meta.Key" content="@meta.Value" />
   }
</head>

Well isn't that nice! ๐Ÿ‘๐Ÿ‘

Believe it or not, even with all this sweet C# awesomeness, we still have use cases we don't handle. ๐Ÿ˜ฎ

What if we want to have something like the og:site_name set for the whole site but also want to pull this value from configuration?

Or, maybe we want to provide a fallback image in case one isn't set in the SiteMeta?

We could even encounter a scenario where a SiteMeta was never passed to the SiteMetaService.Set() call, which means we'd get a NullReferenceException at runtime when we try to access its properties! ๐Ÿ˜จ

Single Responsibility Principle (SRP)

We could put the validation and configuration for generating SiteMeta in the SiteMetaService, but that increased complexity will make it harder to test.

Instead, let's keep the roles of each of our classes focused, making sure to abide by the Single Responsibility Principle. ๐Ÿค—

This class below helps us standardize the values of our SiteMeta based on the requirements above:

public interface ISiteMetaStandardizer
{
    SiteMeta Standardize(SiteMeta siteMeta);
}

public class SiteMetaStandardizer : ISiteMetaStandardizer
{
    private readonly Dictionary<string, string> defaultMeta;

    public SiteMetaStandardizer(string defaultSiteName, string defaultImage)
    {
        Guard.Against.NullOrWhiteSpace(defaultSiteName, nameof(defaultSiteName));
        Guard.Against.NullOrWhiteSpace(defaultImage, nameof(defaultImage));

        defaultMeta = new Dictionary<string, string>
        {
            { SiteMeta.OGImage, defaultImage },
            { SiteMeta.OGSiteName, defaultSiteName }
        };
    }

    public SiteMeta Standardize(SiteMeta siteMeta)
    {
        if (siteMeta is null)
        {
            return new SiteMeta(defaultMeta[SiteMeta.OGSiteName], defaultMeta);
        }

        foreach (var meta in defaultMeta)
        {
            if (!siteMeta.Metas.ContainsKey(meta.Key))
            {
                siteMeta.AddMeta(meta.Key, meta.Value);
            }
        }

        return siteMeta;
    }
}

We can register an instance of this type in our IoC container, as a Singleton, constructing it with values from Kentico's SettingsKeyInfoProvider or from AppSettings.

Now we just need to update our SiteMetaService to use the ISiteMetaStandardizer:

public class SiteMetaService : ISiteMetaService
{
    private SiteMeta currentSiteMeta;
    private readonly standardizer;

    public SiteMetaService(ISiteMetaStandardizer standardizer)
    {
        Guard.Against.Null(standardizer, nameof(standardizer));

        this.standardizer = standardizer;
    }

    public SiteMeta Get() => standardizer.Standardize(currentSiteMeta);

    public void Set(SiteMeta siteMeta)
    {
        Guard.Against.Null(siteMeta, nameof(siteMeta));

        currentSiteMeta = siteMeta;
    }
}

If you've been coding along, you should now be able to use the ISiteMetaService in some of your MVC routes, pulling data from the Kentico pages you fetch from the database. ๐Ÿ˜‰

Wrap Up

That was a lot of coding, and hopefully you found it helpful and enlightening.

What did we accomplish?

  • โœ… We now have a type-safe and validated way of setting and accessing our SiteMeta values from anywhere in the application.
  • โœ… We leveraged ASP.NET MVC's pipeline to apply our SiteMeta values to the ViewBag for every response.
  • โœ… We created focused and testable classes that compose together as a flexible and power system.

Want to enhance what we built here?

You can try adding additional methods to the SiteMeta class that set specific <meta> tags, like og:image.

You could add more logic to the SiteMetaStandardizer, setting defaults based on things like the current url, controller, or anything else Kentico, ASP.NET, and MVC make available to us.

I was also thinking about adding extension methods to Kentico's *Info or PageType classes to turn them into SiteMeta (ex: skuInfo.ToSiteMeta();)

If you have any suggestions, I'd love to hear them. Maybe we can turn this into an open source project for other Kentico developers to use?! ๐Ÿ’ช


If you are looking for additional Kentico content, checkout the Kentico tag here on DEV:

or my Kentico 12: Design Patterns series.

Posted on by:

seangwright profile

Sean G. Wright

@seangwright

dev lead @WiredViews, founding partner @craftbrewingbiz. @Kentico Xperience MVP. love to learn / teach web dev & software engineering, collecting vinyl records, mowing my lawn, craft ๐Ÿบ

Discussion

pic
Editor guide
 

Hi this whole Series is great, especially as I'm trying to get to grips with MVC as opposed to the Portal version.

I have a query as I'm tying to implement the simple approach. I have set up the ViewBag as above however I am using the TemplateResult method rather than View.

The ViewBag Properties aren't showing on the page and I wonder if that has anything to do with use of TemplateResult?

ViewBag.MetaPageDescription = node.GetInheritedValue("DocumentPageDescription");
ViewBag.MetaPageKeyWords = node.GetInheritedValue("DocumentPageKeyWords");

return new TemplateResult(node.DocumentID);
 

@oliverfurmage

I believe for Template pages you need to specify a Layout, which means whatever you've done for your primary site _Layout.cshtml you will need to do in the Template page's layout as well to get Site Meta information to work correctly.

ViewBag should work fine even in this scenario (however I haven't actually tested it).


On the docs page for MVC Page Templates it mentions needing to specify a layout for the template Razor file.

Use MVC layouts with the template view for any shared output code (based on your requirements, you can use your site's main layout, a dedicated layout for page templates, etc.).

This can be done through an explicit definition at the top of the Razor file:

@{
    ViewBag.Title = "Home Page";
    Layout = "~/Views/Shared/_myLayoutPage.cshtml";
}

Or through a _ViewStart.cshtml file to set the layout for an entire subfolder.

 

@seangwright

I had already set the Layout of the template file and I could see elements from my _Layout file (for example the header), however it seems to me that the ViewBag variables set in the controller were not being passed through.

To get round this I decided to do the following on the template file;

@{
    ViewBag.Title = Model.Page.DocumentName;
    Layout = "~/Shared/Views/_Layout.cshtml";
    ViewBag.MetaPageDescription = Model.Page.GetInheritedValue("DocumentPageDescription");
    ViewBag.MetaPageKeyWords = Model.Page.GetInheritedValue("DocumentPageKeyWords");
}

Interesting! Good to know there is a solution like this ๐Ÿง.