Hello Headless Blog
Hello World
I decided to start a work-related blog to share knowledge, solutions, and experiences about different kinds of technologies. I mostly work with Azure and MS technology in general so the content of this blog will be concentrated on cloud and MS tech. I will use this site as a place to test and pilot new technologies:). First I thought that I'd build this blog on top of WordPress. I have slightly experience with 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 it 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 the tech behind the hood of this blog
This blog is a PaaS application that is hosted in Azure. The application is a normal ASP.NET Core web app created in an 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. The 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.
How to start with ButterCMS?
Next, I share some code snippets of how ButterCMS is used on this site. The 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 on the ButterCMS homepage.
Install the it 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 it 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 a 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 the 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 the 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 the top navigation element as an MVC ViewComponent. The component fetches pages from ButterCMS by using the 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 about how the content page model is working in ButterCMS.
ButterCMS Admin interface
WYSIWYG editor
The editor is sufficient for 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 the publishing date, categories, tags, etc.
Post SEO settings
SEO settings allow 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 an hour basic functionalities of the blog were ready for use. API is very well documented and everything has worked very smoothly. I 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.
Comments