Menu

Hello Headless Blog

Hello World

I decided to start a work related blog to share knowledge, solutions and experiences about different kind of technologies. I mostly work with Azure and MS technology in general so content of this blog will be concentrated to cloud and MS tech. I will use this site as a place to test and pilot new technologies:). First I thought that I'll build this blog top of the WordPress. I have slightly experience about WordPress from a few projects and I liked it. WordPress is a good blog platform but I wanted something else. Headless Content Management system is a quite trendy term now so I decided to investigate this more. After googling a while I found ButterCMS which works almost every tech stack and it's free for personal use. Sounds promising.

What is a Headless CMS?

Headless Content Management means that content management is decoupled from the application itself. Content of the site is fetched through API interfaces and produced/stored elsewhere. This approach makes possible to the change solution platform quite easily.

A headless content management system consists primarily of an API as well as the backend technology required to store and deliver content. Source: Keycdn

A few words about tech behind the hood of this blog

This blog is a PaaS application which is hosted in Azure. The application is a normal ASP.NET Core web app created by MVC manner way. ButterCMS provides a Headless CMS solution for this blog. This Azure Web application just has a logic to fetch content data from the ButterCMS API interfaces. Source code of this blog application is available in GitHub.

Solution architecture and dependencies

The following picture describes how the architecture of this solution is created.

undefined

How to start with ButterCMS?

Next I share some code snippets how ButterCMS is used in this site. Main focus is to show how ButterCMS API is used in the application. You can find good .NET code samples from here. Let's start. First create a ButterCMS Account in ButterCMS homepage

Install ButterCMS Nuget package to your Visual Studio-project

Install-Package ButterCMS

ButterCmsController

Through IConfiguration you can fetch secret values (ex. ButterCMS Api key) from the AppSettings or App Configuration. MemoryCache is used to cache content data to prevent extra queries to the ButterCMS API. IStringLocalizer makes possible to get localized content from RESX-files.

public class ButterCmsController : Controller
{
    private ButterCMSClient _cmsClient;
    private readonly AppSettings _appSettings;
    private readonly IMemoryCache _cache;
    private readonly IConfiguration _configuration;
    private readonly IStringLocalizer<SharedResources> _localizer;

    public ButterCmsController(IOptions<AppSettings> settings, IMemoryCache cache, IConfiguration configuration, IStringLocalizer<SharedResources> localizer)
    {
        _appSettings = settings.Value;
        _cache = cache;
        _configuration = configuration;
        _localizer = localizer;

        if (!string.IsNullOrEmpty(_configuration[CommonConstants.ButterCms.ButterCmsApiKey]))
        {
            _cmsClient = new ButterCMSClient(_configuration[CommonConstants.ButterCms.ButterCmsApiKey]);
        }
    }
}

Fetch all post items

ListPosts method parameters are documented here.

public IActionResult Index()
{            
    ViewBag.BlogTitle = _localizer["HeaderBlogTitle"];
    ViewBag.AboutTheSiteDescription = _localizer["AboutTheSiteContent"];

    var cacheKey = CommonConstants.CacheKeys.AllPosts;
    IEnumerable<Post> response = null;
    var cachedData = _cache.Get<IEnumerable<Post>>(cacheKey);

    if (cachedData != null && _appSettings.CacheEnabled)
    {
        response = cachedData;
    }
    else
    {
        if (_cmsClient == null) return View();

        var dataResponse = _cmsClient.ListPosts(int.MinValue, int.MaxValue, true, null, null, null);

        if (dataResponse == null) return View();
        if (dataResponse.Data == null) return View();
        
        response = dataResponse.Data;
        _cache.Set<IEnumerable<Post>>(cacheKey, response, TimeSpan.FromMinutes(_appSettings.PostsCacheDurationInMinutes));
        
    }

    return View(response.OrderByDescending(x => x.Published).ToList());
}

View which shows all posts

@model List<ButterCMS.Models.Post>;

@foreach (var post in Model)
{
    <article class="brick entry format-standard animate-this">

        @if (!string.IsNullOrEmpty(@post.FeaturedImage))
        {
            <div class="entry-thumb">
                <a id="index-post-imagelink-@Uri.EscapeDataString(post.Slug)" href="@string.Concat(CommonConstants.Routes.Blog, Uri.EscapeDataString(post.Slug))" class="thumb-link">
                    <img src="@post.FeaturedImage" alt="building">
                </a>
            </div>
        }

        <div class="entry-text">
            <div class="entry-header">
                <div class="entry-meta">
                    <span class="cat-links">
                        @foreach (var category in post.Categories)
                        {
                            <a id="post-category-@Uri.EscapeDataString(category.Slug)" href="@string.Concat(CommonConstants.Routes.Category, Uri.EscapeDataString(category.Slug))">@category.Name</a>
                        }
                    </span>
                </div>

                <h1 class="entry-title"><a id="index-post-link-@Uri.EscapeDataString(post.Slug)" href="@string.Concat(CommonConstants.Routes.Blog, Uri.EscapeDataString(post.Slug))">@post.Title</a></h1>

            </div>
            <div class="entry-excerpt">
                @if (post.Published.HasValue)
                {
                    @post.Published.Value.ToString("dd.MM.yyyy HH:mm")
                }
                <br />
                @post.Summary
            </div>
        </div>

    </article> <!-- end article -->
}

Show single blog post content

Add new action to the ButterCmsController. This action fetches post data from API with a slug-parameter.

[Route("blog/{slug}")]
public async Task<ActionResult> ShowPost(string slug)
{
    ViewBag.BlogTitle = _localizer["HeaderBlogTitle"];

    if (string.IsNullOrEmpty(slug)) return View(CommonConstants.Views.Post);

    var cacheKey = string.Concat(CommonConstants.CacheKeys.ShowPost,"_", slug);
    PostResponse response = null;
    var cachedData = _cache.Get<PostResponse>(cacheKey);
    if(cachedData != null && _appSettings.CacheEnabled)
    {
        response = cachedData;
    }
    else
    {
        if (_cmsClient == null)
            return View(CommonConstants.Views.Post);
        var postResponse = await _cmsClient.RetrievePostAsync(slug);
        if(postResponse != null)
        {
            response = postResponse;
            _cache.Set(cacheKey, response, TimeSpan.FromMinutes(_appSettings.PostCacheDurationInMinutes));                    
        }
    }
    return View(CommonConstants.Views.Post, response);
}

Post View

@model ButterCMS.Models.PostResponse;

@section Metadata
{
    <title>@Model.Data.SeoTitle @ViewBag.BlogTitle</title>
    <meta name="description" content="@Model.Data.MetaDescription">
    <meta name="author" content="@Model.Data.Author.FirstName @Model.Data.Author.LastName">
}

<!-- content
   ================================================== -->
<section id="content-wrap" class="blog-single">
    <div class="row">
        <div class="col-twelve">

            <article class="format-standard">

                @*@if (!string.IsNullOrEmpty(Model.Data.FeaturedImage))
                {
                    <div class="content-media">
                        <div class="post-thumb">
                            <img src="@Model.Data.FeaturedImage">
                        </div>
                    </div>
                }*@

                <div class="primary-content">

                    <h1 class="page-title">@Model.Data.Title</h1>

                    <ul class="entry-meta">

                        @if (Model.Data.Published.HasValue)
                        {
                            <li class="date">@Model.Data.Published.Value.ToString("dd.MM.yyyy HH:mm")</li>
                        }

                        <li class="cat">
                            @foreach (var category in Model.Data.Categories)
                            {
                                <a id="post-category-@Uri.EscapeDataString(category.Slug)" href="@string.Concat(CommonConstants.Routes.Category, Uri.EscapeDataString(category.Slug))">@category.Name</a>
                            }
                        </li>
                    </ul>

                    <p>@Html.Raw(Model.Data.Body)</p>

                </div>

                <!-- end entry-primary -->
                <div class="pagenav group">
                    @{
                        if (Model.Meta.PreviousPost != null)
                        {
                            <div class="prev-nav">
                                <a id="post-previous-@Uri.EscapeDataString(Model.Meta.PreviousPost.Slug)" href="@string.Concat(CommonConstants.Routes.Blog, Uri.EscapeDataString(Model.Meta.PreviousPost.Slug))" rel="prev">
                                    <span>Previous</span>
                                    @Model.Meta.PreviousPost.Title
                                </a>
                            </div>
                        }
                    }
                    @if (Model.Meta.NextPost != null)
                    {
                        <div class="next-nav">
                            <a id="post-next-@Uri.EscapeDataString(Model.Meta.NextPost.Slug)" href="@string.Concat(CommonConstants.Routes.Blog, Uri.EscapeDataString(Model.Meta.NextPost.Slug))" rel="next">
                                <span>Next</span>
                                @Model.Meta.NextPost.Title
                            </a>
                        </div>
                    }
                </div>

            </article>


        </div> <!-- end col-twelve -->
    </div> <!-- end row -->

    <div class="comments-wrap">
        <div id="comments" class="row">
            <div class="col-full">
                <!--Disqus-->
                <div id="disqus_thread"></div>
                <script>
                    (function () {
                        var d = document, s = d.createElement('script');
                        s.src = 'https://disqus.com/embed.js';
                        s.setAttribute('data-timestamp', +new Date());
                        (d.head || d.body).appendChild(s);
                    })();
                </script>
                <noscript>Please enable JavaScript to view the <a href="https://disqus.com/?ref_noscript">comments powered by Disqus.</a></noscript>
            </div>
        </div>
    </div>

</section> <!-- end content -->

Blog search

Index view is used to show content of posts.

[Route("search/{s?}")]
public IActionResult Search(string s)
{
    if(string.IsNullOrEmpty(s)) return View();

    ViewBag.BlogTitle = _localizer["HeaderBlogTitle"];
    ViewBag.AboutTheSiteDescription = _localizer["AboutTheSiteContent"];

    var cacheKey = string.Concat(CommonConstants.CacheKeys.Search, "_", s);
    IEnumerable<Post> response = null;
    var cachedData = _cache.Get<IEnumerable<Post>>(cacheKey);

    if (cachedData != null && _appSettings.CacheEnabled)
    {
        response = cachedData;
    }
    else
    {
        if (_cmsClient == null)
            return View();
        var dataResponse = _cmsClient.SearchPosts(s, int.MinValue, int.MaxValue);

        if (dataResponse == null) return View(CommonConstants.Views.Index);
        if (dataResponse.Data == null) return View(CommonConstants.Views.Index);
        
        response = dataResponse.Data;
        _cache.Set(cacheKey, response, TimeSpan.FromMinutes(_appSettings.PostsCacheDurationInMinutes));
        
    }

    return View(CommonConstants.Views.Index, response.OrderByDescending(x => x.Published).ToList());
}

Show posts by category

Index view is used to show content of posts.

[Route("blog/category/{slug}")]
public async Task<ActionResult> ShowPostsByCategory(string slug)
{
    ViewBag.BlogTitle = _localizer["HeaderBlogTitle"];
    ViewBag.AboutTheSiteDescription = _localizer["AboutTheSiteContent"];

    if (string.IsNullOrEmpty(slug)) return View(CommonConstants.Views.Index);

    var cacheKey = string.Concat(CommonConstants.CacheKeys.ShowPostsByCategory, "_", slug);
    IEnumerable<Post> response = null;
    var cachedData = _cache.Get<IEnumerable<Post>>(cacheKey);

    if (cachedData != null && _appSettings.CacheEnabled)
    {
        response = cachedData;
    }
    else
    {
        if (_cmsClient == null)
            return View();

        var dataResponse = await _cmsClient.ListPostsAsync(int.MinValue, int.MaxValue, true, null, slug, null);

        if (dataResponse == null) return View(CommonConstants.Views.Index);
        if (dataResponse.Data == null) return View(CommonConstants.Views.Index);
        
        response = dataResponse.Data;
        _cache.Set(cacheKey, response, TimeSpan.FromMinutes(_appSettings.PostsCacheDurationInMinutes));
        
    }
    return View(CommonConstants.Views.Index, response.OrderByDescending(x => x.Published).ToList());
}

I have also implemented top navigation element as a MVC ViewComponent. Basically component fetches pages from ButterCMS by using ListPagesAsync method.

Blogs usually have also normal content pages (ex. about, contact etc.). Page contents are also fetched from the ButterCMS API. I will later blog how content page model is working in ButterCMS.

ButterCMS Admin interface

WYSIWYG editor

Editor is totally sufficient for a normal use. You can easily change styles and add images, links and tables.

WYSIWYG-editor supports code formatting with the most common languages/markups.

Post metadata settings

From the metadata section of the post you can change publishing date, categories, tags etc.

Post SEO settings

SEO settings allows you to change from URL of the post, title and meta description.

Summary

I implemented ButterCMS API queries to the default Visual Studio MVC project template and after hour basic functionalities of the blog are ready for use. API is very well documented and everything has worked very smoothly. I definitely recommend ButterCMS for your headless content management system.

Thank you for reading. Later I will blog more about ButterCMS. This was a very short walk-through of the features.

Links

Comments