Building a Minimal Blog in Pure Elixir With Notion as a Cms
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.Cowboystarts an HTTP server using Cowboy, listening on port4000.- The
plug: NotionBlog.Routerpart tells Cowboy to use our router to handle incoming requests. - The
Supervisorensures that the web server is supervised and restarted if it crashes.
Running the Web Server #
Now, start the application:
You should see output indicating that the web server is running on port 4000. Open a browser and visit:
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
Loggerto track incoming requests for debugging. - Optimize for production: Configure
Cowboyfor 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
Shareand 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/0sends aPOSTrequest to query all articles from the Notion database.get_article/1sends aGETrequest 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/3function: Loads and evaluates an EEx template, passing the necessary assigns.
Example article.html.eex
#
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>
Example blog.html.eex
#
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:
Visit:
http://localhost:4000/blogto see the list of articles.http://localhost:4000/blog/:idto 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