This website started as a simple keyword rank tool that I built to fetch the top 100 search results from google for specific keywords and highlight the domain I was looking for. Additionally, I wanted the ability to crawl those 100 results in order to find links pointing to said domain. With the need for concurrency and web sockets, Elixir and Phoenix were an easy choice for the task.
When I decided to add a blog however, I couldn't find anything polished enough for my liking in the Elixir ecosystem. The website's goal is to provide free SEO tools and I want to focus my time on building that, not a blogging platform. As such, I decided to simply settle for an existing, self-hosted solution, even if not built in Elixir.
There are many choices out there for blogging, but as far as SEO is concerned, you'll have a hard time finding anything as mature as Wordpress. There are plugins that solve pretty much all tricky SEO concerns such as generating sitemaps, valid structured data, open graphs, twitter tags and much more.
I will start by saying that I have absolutely nothing against modern object-oriented PHP, in fact I still occasionally use it to this day.
There is something to say about the procedural mess that the Wordpress codebase is however. As someone that enjoys functional programming, all those globals make me shiver. And I won't even mention the code quality of some of the third-party plugins.
I also remember trying to help a friend with his Wordpress website a while back only to realise how bloated and complicated it all becomes as soon as you dabble with plugins.
But is there another option?
After reading blogs and articles, Ghost seems to be emerging as a modern, minimalistic alternative that takes care of all the SEO bits out of the box.
Here is how they describe themselves:
Ghost is a free and open source blogging platform written in JavaScript and distributed under the MIT License, designed to simplify the process of online publishing for individual bloggers as well as online publications.
After trying out the demo, the decision was made. The interface is clean and resembles a lot to Medium, putting the focus on the content.
As such, I've decided to explore how I could integrate a Ghost blog in my existing Phoenix application.
Here is what I mean by subdomain vs directory:
https://blog.seoapps.dev/article-slug
-> subdomainhttps://seoapps.dev/blog/article-slug
-> directoryThe easiest option by far would be to create the blog on a subdomain, and handle the blog as a stand alone application with its own DNS entries.
There is a dilemma with this approach however: it has been shown that the subdomain approach is worse for SEO.
I won't enter into too much detail here, but here is a quick TL;DR:
Backlinks going to the root domain don't improve the SEO profile of the subdomain and vice-versa. When using a directory, backlinks to my tools and blog improve the SEO profile of the whole website by impacting domain authority etc.
If you google "subdomain vs directory", you'll find hundreds of articles and studies on the subject.
Okay, so how do we integrate Ghost as a route in the Phoenix router? Do we somehow run Javascript in Elixir?
I mean, there probably is a way to make this work. But there is a much easier solution! What if we used the subdomain hosting solution and proxied the requests through?
Since I am already hosting my Elixir application with Render (and really liking it!), I decided to stick with them for the Ghost install. Which turned out to be an excellent choice since they have an incredibly simple guide on how to do it.
All I needed to do is fork a repository that they provide, create a new web service in the render admin panel and select the forked Github project.
Just like that, I had my Ghost blog up and running. I didn't even need to create the subdomain as render creates subdomains out of the box. They look something like this: [application name][slug].onrender.com
, which works just fine in this situation.
To login into the Ghost admin panel, simply navigate to /ghost
.
After creating a few test pages and uploading a few images, I decided to get started on the reverse proxy.
We're in luck, the concept of Plugs used in the Elixir world is perfect for this. There is a package called reverse proxy plug which does the heavy lifting for us. Let's install the package and get to work!
{:reverse_proxy_plug, "~> 1.2.1"}
By default, Phoenix adds a parser plug into our endpoint plug pipeline, as it assumes that we want to run it for every request. This is however not the case here, as we don't want to run it for our reverse proxy.
As such, we'll move the following code from the Endpoint.ex
file to the router's :browser
pipeline.
plug Plug.Parsers,
parsers: [:urlencoded, :multipart, :json],
pass: ["*/*"],
json_decoder: Phoenix.json_library()
Next, we will add the reverse proxy to our router:
forward("/blog", ReverseProxyPlug, upstream: "https://ghost-8mkx.onrender.com")
This will catch any requests going to /blog
and forward it to our Ghost application.
Here is what my router file ended up looking like:
After trying it out, everything seems to work, except for the images and the styles.
What went wrong?
Looks like the uploaded content and static files are not going through the proxy, as they are loaded from the following URL: /assets/js/app.min.js?v=90ee256fa4
and /content/images/2020/05/logo--5---3--1.png
By not going through a /blog
route, my Phoenix server tries to serve them from the local website, not Ghost.
We can fix this by updating the forked repository's updateConfig.js
file. We need to append /blog
to the externalURL like so:
const externalURL = process.env.url || process.env.RENDER_EXTERNAL_URL + "/blog";
After a deploy, we now get a 404 error.
Of course, the Phoenix route!
forward("/blog", ReverseProxyPlug, upstream: "https://ghost-8mkx.onrender.com/blog")
Okay, we can see that the styles are fixed, but not the uploaded images. The reason for this is that Ghost uses the file system for the upload paths. Now that we added a blog
folder to our URL, Ghost expects the images to be nested in a blog
folder. The easiest fix is to simply delete the uploads and re-upload them, as they will automatically go into the right folder as per configuration.
Okay, we're looking good. Visually, everything is working. But there is a catch!
When inspecting the HTML, we can see that the canonical tag is pointing to the onrender.com
URL, basically negating all work done so far in terms of using a directory instead of a subdomain.
If you are unsure what a canonical URL is or why it matters, you can read Google's recommendations here.
Ghost allows to overwrite canonical URLs on a post to post basis, but this is not really what we want. This feature is aimed at guest blogging etc.
What we want is to blanket change all canonical URLs to point to https://seoapps.dev/blog
.
The way to do this is to change the externalURL once again, by not only appending /blog
but actually using our own domain name. Here is what we're left with:
const externalURL = "https://seoapps.dev/blog";
One more deploy and we should be good to go!
Ghost automatically generates sitemaps for us when we create/update pages, blog posts, tags and authors. They generate the following 4 sitemaps:
The 4 sitemaps are then referenced by the root sitemap/blog/sitemap.xml
using a sitemapindex
.
I have an idea! Let's use same strategy in our own Phoenix sitemap! Simply rename your existing sitemap.xml
to sitemap-pages.xml
, and create a new sitemap.xml
.This sitemap.xml
will use a sitemapindex
to include our own sitemap-pages.xml
and the Ghost /blog/sitemap.xml
.
Now everything is neatly organised and loaded properly!
Here is a quick overview of the structure:
Next we just need to tell Phoenix that we want those sitemaps served statically. We can do so by updating the static plug in our endpoint. And let's take this opportunity to also add gzip and cache-control headers.
plug Plug.Static,
at: "/",
from: :seo,
gzip: true,
headers: %{"cache-control" => "public, max-age=31536000"},
only: ~w(css fonts images js favicon.ico robots.txt sitemap.xml sitemap-pages.xml)
Let's submit it to Google and make sure it's all working, this is such a neat solution and...
Bummer, this doesn't cut it.
Googling the error returns a simple solution however: simply submit the two sitemaps separately.
Ghost uses something called "themes" for, uhhh... themes.
It is a folder containing a mandatory set of good ol' handlebars files.
For example, there is a default.hbs
file which is what we would know as layout
in Phoenix. And then it has a handlebars file for:
On top of that it has a partials
folder which contains files such as footer.hbs
etc.
With my Phoenix application using Bulma CSS, I found a free theme built for Bulma and adapted it to my needs. I essentially removed all styles and just kept the properly formatted handlebars file. The style file now simply @import
's my existing Phoenix app.sass, this way they share the same styles and code.
All in all, the whole exercise took maybe 4 hours. About the same time it took me to write this blog post 😮.
It was mainly spent trying to figure out how to set up Ghost. If I had to do it over with my current knowledge, I would probably knock it over in 30 mins.
It wasn't overly challenging and I'm happy with the final result. I started to appreciate the Ghost writing editor more and more as I am writing this post. The interface is clean, drag and dropping images is a breeze, the code blocks are simple to create, the autosave functionality actually bailed me out once.
There is no way I would have anything as nice and polished had I decided to roll out my own Phoenix solution. Plus I don't have to worry about all the SEO stuff, Ghost does it all out of the box.
The reverse proxy adds a tiny bit of delay to the HTTP request, but it's very much negligible.
I am yet again impressed in how Elixir and Phoenix are performing. The changes were so minimal and the solution so elegant. Simply tweak a couple of plugs and boom, everything just magically works. I spent maybe 5 minutes on the Phoenix application, if that.
Also if you are looking to create a blogging solution in Elixir, definitely draw inspiration from Ghost!
This is the first time I set up a blog and actually blog about something. Plugging the text into the word counter tool gives me the following statistics:
I hope you enjoyed the read.