Serverless blog with 11ty, GraphCMS and Firebase
Tags: 11ty, eleventy, firebase, graphcms
Published: 2022-02-28 14:59:58

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](https://picocss.com/).

So I picked [11ty](https://www.11ty.dev/) (as I'm very fond of it) and [Firebase Hosting](https://firebase.google.com/), 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](https://graphcms.com/), a competitor of the beloved [DatoCMS](https://www.datocms.com/). 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.

## The CMS

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.

![GraphCMS](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/kr0fv3jp1sy6uzc4ah9s.png)

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](https://picocss.com/) underneath.

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

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

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

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

    {% if post.image %}
    <div
      class="article__img"
      style="
        background-image: url('{{ post.image.url }}');
      "
    ></div>
    {% endif %}
  </article>
  {% endfor %}
</section>

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

A blog detailpage will look like this

```liquid
# src/_post.liquid
---
layout: base
pagination:
  data: posts
  size: 1
  alias: post
  reverse: true
permalink: 'post/{{post.id}}/'
---

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

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

  {{ post.content.html }}
</main>

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

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

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

  const authToken = 'YOUR_GRAPHCMS_AUTH_TOKEN';
  const endPoint = 'https://api-eu-central-1.graphcms.com/v2/YOUR_GRAPHCMS_PROJECT_ID/master';
  const client = new GraphQLClient(endPoint, {
    headers: {
      authorization: `Bearer ${authToken}`,
    },
  });

  const query = gql`
    query BlogPosts {
      blogPosts(orderBy: date_DESC, where: { visible: true }) {
        createdAt
        visible
        intro
        title
        date
        id
        content {
          html
        }
        image {
          url
        }
      }
    }
  `;

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

  return blogPosts.map((p) => {
    const date = new Date(p.date);
    p.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`.

### Firebase

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.

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

In `.firebaserc` you will see your configured Firebase project.

### package.json

```json
  "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.

```bash
$ 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.

```yml
{% raw %}
name: Deploy to Firebase
on:
  workflow_dispatch:
  push:
    branches:
      - main
jobs:
  build_and_deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - run: npm ci && npm run build
      - uses: FirebaseExtended/action-hosting-deploy@v0
        with:
          repoToken: '${{ secrets.GITHUB_TOKEN }}'
          firebaseServiceAccount: '${{ secrets.FIREBASE_SERVICE_ACCOUNT }}'
          channelId: live
          projectId: YOUR_FIREBASE_PROJECT_ID
{% endraw %}
```

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 
```
https://console.firebase.google.com/project/
YOUR_FIREBASE_PROJECT_ID/settings/serviceaccounts/adminsdk
```

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.

```bash
$ 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.

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

export const handler = functions.https.onRequest(async (request, response) => {
  const { status } = await fetch(
    'https://api.github.com/repos/timvermaercke/GITHUB_ROJECT/actions/workflows/main.yml/dispatches',
    {
      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.' });
    return;
  }

  response.send({ statusCode: 400 });
  return;
});
```

The Access Token for GitHub can be generated [here](https://github.com/settings/tokens).

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.

![Webhooks](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/l934tjwapbtokbx00r8p.png)

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.

## Conclusion

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.