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 thisViewBag
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 thatSiteMeta
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 theViewBag
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.
Top comments (4)
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?
@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.
This can be done through an explicit definition at the top of the Razor file:
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;
Interesting! Good to know there is a solution like this ๐ง.