The idea behind this experiment was to ignore modern practices: no Vercel and the complete React ecosystem. Back to basics and back to the roots of the interwebs with a static blog that's just html and some minimal css.

So I picked 11ty (as I'm very fond of it) and Firebase Hosting (Google Cloud ❤️), since that is basically the equivalent of a public dropbox folder. The content should be managed apart from the code though, as I don't want my blog to be managed from VS Code and Git.

In comes GraphCMS, a competitor of the beloved DatoCMS. It lacks some features - like repeatable blocks and the UI is a bit too cluttered, but has a generous free tier. For a blog, this will do just fine.


With GraphCMS, you just "click" your data models together. They provide a GrahpQL playground with all use cases predefined for your data models and queries.


Now it's just populating the posts in the content tab!

The website itself

The code itself is nothing fancy. There are only 2 pages and 1 data file. As you can see, there aren't a lot of CSS classes, as I'm using pico.css underneath.

The homepage will show the latest 10 posts, and will generate some pagination if there's a need to.

# src/index.liquid

layout: base
data: posts
size: 10
permalink: '{% if pagination.pageNumber == 0 %}index.html{% else %}{{ pagination.pageNumber|plus:1 }}/index.html{% endif %}'

<header class="container pb-0">
<h1>Title of your blog</h1>
<h2>Some subtitle for your blog.</h2>

<section class="container">
{% for post in pagination.items %}
<article class="article--home">
<div class="article__content">
<h3>{{ post.title }}</h3>
<h4>{{ }}</h4>
{{ post.intro }}
<a href="/post/{{ }}" role="button">Verder lezen</a>

{% if post.image %}
background-image: url('{{ post.image.url }}');

{% endif %}
{% endfor %}

{% if pagination.hrefs.length > 1 %}
<section class="container pagination">
{% for link in pagination.hrefs %}
<a href="{{ link }}" class="{% if page.url == link %}secondary{% endif %}">
{% if link[1] %}{{ link[1] }}{% else %}1{% endif %}
{% endfor %}
{% endif %}

A blog detailpage will look like this

# src/_post.liquid

layout: base
data: posts
size: 1
alias: post
reverse: true
permalink: 'post/{{}}/'

<main class="container">
<h1>{{ post.title }}</h1>
<h2>{{ }}</h2>

{% if post.image %}
<img src="{{ post.image.url }}" alt="{{ post.title }}" />
{% endif %}

{{ post.content.html }}

<section class="container post-nav">
{% if pagination.nextPageHref %}
<a href="{{ pagination.nextPageHref }}" class="secondary" role="button">&larr;</a>
{% else %}
{% endif %}
{% if pagination.previousPageHref %}
<a href="{{ pagination.previousPageHref }}" class="secondary" role="button">&rarr;</a>
{% else %}
{% endif %}

Then, where does this data come from? Well from a data file that fetches the data from GraphCMS. It's located under src/_data.

# src/_data/posts.js
module.exports = async () => {
const { GraphQLClient, gql } = require('graphql-request');

const authToken = 'YOUR_GRAPHCMS_AUTH_TOKEN';
const endPoint = '';
const client = new GraphQLClient(endPoint, {
headers: {
authorization: `Bearer ${authToken}`,

const query = gql`
query BlogPosts {
blogPosts(orderBy: date_DESC, where: { visible: true }) {
content {
image {

const { blogPosts } = await client.request(query);

return => {
const date = new Date(; = date.toLocaleDateString('nl', { year: 'numeric', month: 'long', day: 'numeric' });

return p;

The code above will return all your posts. 11ty is smart enough to convert it into paginated overview pages and to generate a seperate detail page for each blog post.

Bringing it live

With Firebase, deploying a build of a website is easy as 1-2-3.

Just install firebase in your project and configure your build and deploy script in package.json.


Installing and configuring Firebase is done with an npm package.

$ npx firebase init

You should only select hosting when prompted. Create a new project, or use one you already have.

Your firebase.json should look like this.

"hosting": {
"public": "_site",
"cleanUrls": true,
"ignore": ["firebase.json", "**/.*", "**/node_modules/**"],

In .firebaserc you will see your configured Firebase project.


  "scripts": {
"watch:eleventy": "npx @11ty/eleventy --serve",
"watch:sass": "npx sass src/_scss:_site/css --watch",
"dev": "export $(cat .env | xargs) && npm run watch:eleventy & npm run watch:sass",
"build": "export ELEVENTY_ENV=prod && rm -rf _site && npx sass src/_scss:_site/css --no-source-map && npm run postcss && npx @11ty/eleventy",
"release": "standard-version",
"postcss": "postcss _site/css/app.css -o _site/css/app.css --use autoprefixer -b 'last 2 versions' | postcss _site/css/app.css -o _site/css/app.css --use cssnano"

From here, it's only executing these commands to deploy your GraphCMS-powered blog.

$ npm run build && npx firebase deploy --only hosting

Et voila, your blog is live.

Automating deployments

Now, we should configure a webhook in GraphCMS that triggers the manual process of building and deploying the new package to Firebase.

Firebase doesn't build our package, so we'll have to resort to some CI/CD solution. GitHub Actions to the rescue!

With this "workflow" you can trigger an automatic build and deploy to Firebase on pushes on the main branch.

name: Deploy to Firebase
- main
runs-on: ubuntu-latest
- uses: actions/checkout@v2
- run: npm ci && npm run build
- uses: FirebaseExtended/action-hosting-deploy@v0
repoToken: '${{ secrets.GITHUB_TOKEN }}'
firebaseServiceAccount: '${{ secrets.FIREBASE_SERVICE_ACCOUNT }}'
channelId: live

The FIREBASE_SERVICE_ACCOUNT secret is one that you should generate locally and store in GitHub Secrets within your repo.

You can generate one in the Firebase console on

Now pushing to the repo on the main branch, triggers a new build and deploy through GitHub Actions.

But, and this one is not so fun, GitHub doesn't provide a webhook that triggers that same action, so you'll have to provide one yourself, by building a Cloud Function.

$ npx firebase init functions
$ cd ./functions && npm install

In your freshly generated ./functions/src/index.ts add this code, that accept only HTTP call to the endpoint and will trigger a GitHub trigger of your main.yaml workflow.

import * as functions from 'firebase-functions';
import fetch from 'node-fetch';

export const handler = functions.https.onRequest(async (request, response) => {
const { status } = await fetch(
method: 'POST',
body: JSON.stringify({
ref: 'main',
headers: {
Authorization: 'Bearer YOUR_GITHUB_AUTH_TOKEN',

if (status === 204) {
response.send({ statusCode: 200, body: 'GitHub API was called.' });

response.send({ statusCode: 400 });

The Access Token for GitHub can be generated here.

Now, when you deploy this function with npx firebase deploy --only functions, you'll get a URL. Calling this URL will trigger the same behavior as pushing to main, thus calling the build and deploy step. You can try this by visiting the link.

Copy that link and paste in the GraphCMS Webhooks UI.


GraphCMS allows you to configure when to call the webhook very precisely, only on certain content models, or when transitioning from certain states to others, or a combination of both.

Now create a new post and publish it.

GraphCMS will call your Firebase Cloud Function, that triggers GitHub Actions to build a new package, by pulling everything from GraphCMS, creating a build and pushing it to Firebase Hosting.


It takes some effort to set up this workflow, but for a static website or blog, it isn't that much of a hassle.

The editors of the blog can get access to GraphCMS and that's the only thing they should be worried about.

All the rest is taken care of by the cloud. 🎤⤵️