- Published on
Building a Minimal Blog in Pure Elixir with Notion as a CMS
- Authors
- Name
- Iván González
- @dreamingechoes
Why This Idea?
The other day, while tweaking my website (built with React and Next.js), I wondered: What if I built something simpler? No Phoenix, no traditional database—just pure Elixir and some lightweight dependencies.
Then it hit me: What if I used Notion as the CMS for a minimalistic blog? It sounded like a fun experiment, even though I knew the Notion API wouldn't be the fastest option. But hey, it's all about having fun and exploring ideas, right?
First Steps: Creating the Elixir App
We’ll start by generating a new Elixir application using mix
and adding only three dependencies:
cowboy
– lightweight web server.jason
– JSON encoding/decoding.httpoison
– HTTP client for interacting with Notion’s API.
Run:
mix new notion_blog --sup
cd notion_blog
Then add these dependencies in mix.exs
:
defp deps do
[
{:cowboy, "~> 2.9"},
{:plug_cowboy, "~> 2.6"},
{:httpoison, "~> 2.1"},
{:jason, "~> 1.4"}
]
end
Run mix deps.get
to fetch them.
Creating a Basic Elixir Web Server
Since we’re keeping things minimal, we’ll use Plug.Router
instead of a full-fledged framework like Phoenix. Plug
is a lightweight Elixir library for building web applications, and it integrates well with Cowboy
, a small and efficient HTTP server.
Setting Up the Router
First, create a module NotionBlog.Router
to define our basic routes:
defmodule NotionBlog.Router do
use Plug.Router
plug(:match)
plug(:dispatch)
get "/" do
send_resp(conn, 200, "<h1>Welcome to the Notion-powered Blog!</h1>")
end
end
Here’s a breakdown of what’s happening:
use Plug.Router
– This module provides a DSL for defining routes.plug(:match)
– This matches incoming requests to the correct route.plug(:dispatch)
– This executes the matched route.get "/"
– Defines a simple GET route for the home page that returns a basic HTML response.send_resp(conn, status_code, body)
– Sends an HTTP response.
Integrating with Plug and Cowboy
Now, we need to set up the application to run this router. Modify lib/notion_blog/application.ex
to supervise the HTTP server:
defmodule NotionBlog.Application do
use Application
def start(_type, _args) do
children = [
{Plug.Cowboy, scheme: :http, plug: NotionBlog.Router, options: [port: 4000]}
]
opts = [strategy: :one_for_one, name: NotionBlog.Supervisor]
Supervisor.start_link(children, opts)
end
end
Explanation
Plug.Cowboy
starts an HTTP server using Cowboy, listening on port4000
.- The
plug: NotionBlog.Router
part tells Cowboy to use our router to handle incoming requests. - The
Supervisor
ensures that the web server is supervised and restarted if it crashes.
Running the Web Server
Now, start the application:
mix run --no-halt
You should see output indicating that the web server is running on port 4000. Open a browser and visit:
http://localhost:4000
You should see:
Welcome to the Notion-powered Blog!
This means the web server is correctly handling HTTP requests.
Enhancements and Best Practices
- Learn more about Plug.Router: Official Documentation.
- Consider logging: Use
Logger
to track incoming requests for debugging. - Optimize for production: Configure
Cowboy
for better concurrency handling.
Setting Up Notion as Our CMS
To use Notion as a CMS for our blog, we need to set up a Notion database and configure our Elixir application to fetch content from it using Notion's API.
Creating a Notion Database
- Go to Notion – Open your Notion workspace and create a new database.
- Add the required fields – Ensure the database contains the following columns:
Title
(Text) – The article title.Date
(Date) – The publication date.Body
(Text) – The article content.
- Share the database – Click on
Share
and add your Notion integration, granting it access to the database. - Copy the database ID – Extract the database ID from the URL (the part after
notion.so/
and before the?
parameter).
Getting an API Key
- Create a Notion integration – Go to Notion API Integrations and create a new integration.
- Copy the API key – Once created, Notion will provide you with a secret API key.
- Store it in the application configuration – Add the API key and database ID to
config/config.exs
:
config :notion_blog,
notion_api_url: "https://api.notion.com/v1",
notion_api_token: System.get_env("NOTION_API_KEY"),
notion_api_version: "2022-06-28",
notion_database_id: System.get_env("NOTION_DATABASE_ID")
Fetching Articles from Notion
To retrieve articles dynamically, we will use the Notion API, which provides a way to interact with Notion databases programmatically. By leveraging Notion's API, we can query our blog database, fetch content, and display it in our Elixir application.
We need to define a module to interact with the Notion API. Using HTTPoison
, we can send HTTP requests to query the database and retrieve articles. This will involve:
- Making a GET request to the Notion API to fetch data from our specified database.
- Parsing the API response to extract article titles, dates, and body content.
- Handling authentication and request headers to ensure proper authorization when communicating with the API.
Notion API Client
To interact with Notion's API efficiently, we will create a dedicated module that handles API requests using HTTPoison
. This module will be responsible for querying our Notion database, retrieving article data, and handling API responses.
Setting Up the Client
Create a module named NotionBlog.Notion.Client
, which will serve as a high-level wrapper around the Notion API. It will include functions for retrieving the database content and fetching specific articles.
defmodule NotionBlog.Notion.Client do
use HTTPoison.Base
@notion_api_url Application.get_env(:notion_blog, :notion_api_url)
@database_id Application.get_env(:notion_blog, :notion_database_id)
def process_request_url(path), do: "#{@notion_api_url}#{path}"
def process_request_headers(headers) do
[
{"Authorization", "Bearer #{Application.get_env(:notion_blog, :notion_api_token)}"},
{"Notion-Version", Application.get_env(:notion_blog, :notion_api_version)},
{"Content-Type", "application/json"}
] ++ headers
end
end
Querying the Notion Database
The function query_database/0
fetches all blog posts stored in Notion.
def query_database do
path = "/databases/#{@database_id}/query"
with {:ok, %HTTPoison.Response{status_code: 200, body: body}} <- HTTPoison.post(process_request_url(path), "{}", process_request_headers([])) do
{:ok, Jason.decode!(body)}
else
_ -> {:error, "Failed to fetch articles"}
end
end
Fetching a Single Article
The function get_article/1
retrieves an article by its ID.
def get_article(article_id) do
path = "/pages/#{article_id}"
with {:ok, %HTTPoison.Response{status_code: 200, body: body}} <- HTTPoison.get(process_request_url(path), process_request_headers([])) do
{:ok, Jason.decode!(body)}
else
_ -> {:error, "Article not found"}
end
end
Explanation
query_database/0
sends aPOST
request to query all articles from the Notion database.get_article/1
sends aGET
request to fetch a specific article.- Both functions return decoded JSON responses or an error tuple if something goes wrong.
This client allows us to seamlessly retrieve and process articles from Notion, enabling our Elixir application to function as a dynamic blog powered by Notion's database.
Testing the API Connection
Before integrating the client into the application, test it in an iex
session:
iex -S mix
NotionBlog.Notion.Client.query_database()
If everything is set up correctly, you should see JSON data returned from Notion containing your stored articles. Now that we have a way to retrieve articles, we can move on to rendering them in our Elixir web application!
Fetching and Rendering Blog Articles
With Notion set up as our CMS, we now need to fetch blog articles from its API and render them in our Elixir web application.
Updating the Router to Render HTML
To display blog articles dynamically, we modify our router to fetch articles from Notion and render them using EEx templates.
defmodule NotionBlog.Router do
use Plug.Router
plug(:match)
plug(:dispatch)
@template_dir Path.expand("./templates", __DIR__)
get "/blog" do
with {:ok, %{"results" => articles}} <- NotionBlog.Notion.Client.query_database() do
render(conn, "blog.html", title: "Blog", articles: articles)
else
_ -> send_resp(conn, 500, "Error fetching articles")
end
end
get "/blog/:id" do
with {:ok, article} <- NotionBlog.Notion.Client.get_page(id) do
render(conn, "article.html", title: article["title"], article: article)
else
_ -> send_resp(conn, 404, "Article not found")
end
end
defp render(conn, template, assigns) do
body = @template_dir |> Path.join(template) |> EEx.eval_file(assigns: assigns)
send_resp(conn, 200, body)
end
end
Explanation
get "/blog"
: Fetches all articles from the Notion database and renders them usingblog.html.eex
.get "/blog/:id"
: Fetches an individual article by its ID and renders it usingarticle.html.eex
.render/3
function: Loads and evaluates an EEx template, passing the necessary assigns.
article.html.eex
Example This template renders an individual blog post in a simple yet structured format:
<!DOCTYPE html>
<html>
<head>
<title><%= @title %></title>
</head>
<body>
<h1><%= @article["title"] %></h1>
<p><strong>Date:</strong> <%= @article["date"] %></p>
<div><%= @article["body"] %></div>
</body>
</html>
blog.html.eex
Example This template lists all articles with links to individual blog pages:
<!DOCTYPE html>
<html>
<head>
<title>Blog</title>
</head>
<body>
<h1>Blog Articles</h1>
<ul>
<% for article <- @articles do %>
<li>
<a href="/blog/<%= article["id"] %>"><%= article["title"] %></a>
</li>
<% end %>
</ul>
</body>
</html>
Testing the Routes
Start the server:
mix run --no-halt
Visit:
http://localhost:4000/blog
to see the list of articles.http://localhost:4000/blog/:id
to view a specific article.
This ensures that our application correctly fetches and renders blog content from Notion dynamically!
Conclusion
And there you have it—a minimalistic blog built in pure Elixir, using Notion as a CMS, with just three dependencies. This project demonstrates how simple it can be to serve and manage blog content without needing a traditional database or a full-fledged web framework like Phoenix.
Demo
Possible Improvements & Follow-Up Exercises
This proof of concept is a great starting point, but there are several ways to expand and improve upon it. Here are some ideas for follow-up exercises to make the project more robust and feature-rich:
- Add Pagination — All blog posts are fetched at once. Implementing pagination can improve performance and usability.
- Implement Caching – Since Notion API requests can be slow, consider adding a caching layer using ETS or an in-memory store like Redis.
- Enhance Markdown Rendering – Notion stores text content in blocks. Implementing a way to parse and render Markdown-like formatting would improve the reading experience.
- Search & Filtering—Add search functionality to filter blog posts by keywords, categories, or tags.
- Form for Creating & Updating Articles – Instead of manually adding content via Notion, build a web form that allows users to create and edit articles directly from the UI and update them in Notion via the API.
- Authentication & Authorization – Restrict the ability to create, update, or delete articles to authenticated users.
- Better Error Handling & Logging – Improve error messages and log API failures for easier debugging.
- Deploy the App – Deploy the blog to a cloud service like Fly.io, Gigalixir, or DigitalOcean to make it publicly accessible.
Final Thoughts
This project was a fun way to explore how to use Elixir in a lightweight, functional way while leveraging Notion as a content management system. Whether you want to expand on this idea or use it as inspiration for a different project, there’s plenty of room for experimentation.
🚀 Now it’s your turn—take it further and have fun building!
GitHub Repository
You can find the full source code for this project on GitHub: Notion Blog Repository