I THINK SOMETIMES

Building a Jamstack blog with free Cloudflare hosting and SEO

If you want to go straight to the sample code and skip the tutorial you can find it here. Please feel free to contribute.

Table of Contents

Introduction

What is Jamstack?

JAM stands for Javascript, APIs, and Markup. With Jamstack we can have an architecture that delivers a website statically whilst still providing dynamic content through Javascript.

Sometimes we need to build websites that are interactive but do not require a lot of processing of information or complex authentication services. Think of simple sites like a business landing page or a blog site in this case. Since most of the content of these websites won't change too frequently we can deliver the content statically, which means that our website for the most part is going to be pre-generated or pre-rendered by our framework.

Jamstack offers many benefits, from performance to low cost, following this tutorial should be completely free. To get more information about Jamstack you can visit this website.

Framework

The framework or static site generator that we are going to be using for this tutorial is Nuxt 3. This framework will handle the generation of the static files that we will be hosting for our website.

You can explore different options and create your own Jamstack with your preferred framework with this website.

Hosting

Thanks to Cloudflare Pages we can host static sites for free1. You can also configure a custom domain if you have one, but since this tutorial is trying to keep everything free I won't get into that.

Creating the project

Let's start by creating our Nuxt.js project. You'll need to have Node.js v18.0.0 or newer and npm installed on your computer.

Run the following command:

npx [email protected] init davidservn-jamstack-blog-sample

Now run the following command to start the development environment:

npm run dev

If you go to localhost:3000 you should see something like this:

nuxt-app-welcome-page

Nuxt welcome page

Installing the dependencies

Let's install the dependencies we are going to need.

Nuxt Content (@nuxt/content)

Nuxt Content will help us manage the blog posts. It is a file-based CMS, which means that our blog posts are going to be written as markdown files on our project and this library will create a pseudo-database with our blog content so that we can fetch the data and create the display for the posts and blog index.

npm install @nuxt/content@^2.12.1

Sitemap (sitemap)

This library will help us with the generation of a sitemap for our website so that it can be indexed by search engines.

npm install sitemap@^8.0.0

Feed (feed)

This library will help us with the creation of an RSS feed for our blog.

npm install feed@^4.2.2

Configuring Nuxt

Now that we have a Nuxt project with the dependencies installed we can start to configure our project.

Let's configure our environment variables:

  1. Create a file named .env on the project's root folder.
  2. Add your Base URL variable like this:
    BASE_URL=https://davidservn-jamstack-blog-sample.pages.dev
    

Note: You can use your custom domain or wait for the hosting configuration, Cloudflare Pages will assign a free domain for your project, something like project-name.pages.dev

Open nuxt.config.ts and make sure to have the following configurations:

export default defineNuxtConfig({
  ssr: true,
  modules: ["@nuxt/content"],
  ...
})

Creating the first blog post

Our blog posts are going to live under the content folder. Here we are going to create markdown .md files like this one:

---
title: My first post
summary: This is my first blog post.
createdAt: 2022-01-21 GMT-6
---

## Introduction

Hi this is my first blog post.

Here is a link to my [website](https://davidservn.com/).

The first fields are used as metadata for the nuxt/content library. We can access those fields when fetching the blog posts (we will see that later when creating the UI).

The name of the file will be used as the route, for example: my-first-post.md will be available at base_url/my-first-post.

Creating the blog post list UI

Let's open the app.vue file and add the following code in order to load page components from the pages folder.

<template>
  <div>
    <NuxtPage />
  </div>
</template>

Create a file named pages/index.vue and add the following code to display a simple list with all our blog posts:

<template>
  <div>
    <div class="header">
      <p class="blog-title">DavidServn's Blog</p>
      <p class="rss-p">
        RSS Feed (
        <a target="_blank" href="/feed.xml">XML</a>
        /
        <a target="_blank" href="/feed.json">JSON</a>
        /
        <a target="_blank" href="/feed.atom">ATOM</a>
        )
      </p>
    </div>
    <div id="blog-list">
      <div v-for="post in posts" :key="post._path">
        <article>
          <h1>
            <a class="title" :href="post._path">
              {{ post.title }}
            </a>
          </h1>
          <p class="desc">{{ post.summary }}</p>
        </article>
        <hr />
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
  const { data: content } = await useAsyncData('index', () => queryContent()
    .only(["_path", "title", "summary"]) // Only fetch the fields that we require to improve performance.
    .sort({ title: 1, createdAt: -1 })
    .find());
  const posts = content.value ?? [];
</script>

We can use the queryContent composable to fetch our blog posts. In this case we use the only() method to only return a small number of fields from each post since we don't need the whole body of our posts and this way we can improve the performance of our blog index.

_path is going to represent the relative URL for our blog post, for example, for my-first-post.md the _path is going to be /my-first-post.

If we go to localhost:3000 we should see how our list looks, it's very simple but you can add some CSS styling to better reflect your personality.

blog-list

The blog post list

Creating the blog post content UI

Let's create the individual blog post display page. Here we are going to fetch a blog post depending on the URL we are loading (this is why we need the post's _path property) and we will display the post's contents.

We need to create a file named pages/[...slug].vue and add the following code to render the body of our blog post:

<template>
  <div>
    <div class="header">
      <p class="blog-title">DavidServn's Blog</p>
      <p class="rss-p">
        RSS Feed (
        <a target="_blank" href="/feed.xml">XML</a>
        /
        <a target="_blank" href="/feed.json">JSON</a>
        /
        <a target="_blank" href="/feed.atom">ATOM</a>
        )
      </p>
    </div>
    <div id="blog">
      <p class="back">
        <a href="/"><span>Back</span></a>
      </p>
      <article>
        <h1>
          {{ post.title }}
        </h1>
        <ContentRenderer :value="post" />
      </article>
      <br />
      <p class="footer">
        Follow me on
        <a target="_blank" href="https://twitter.com/DavidServn">Twitter</a> to
        be notified about new posts or through RSS (
        <a target="_blank" href="/feed.xml">XML</a> /
        <a target="_blank" href="/feed.json">JSON</a> ).
      </p>
    </div>
  </div>
</template>

<script setup lang="ts">
  let path = useRoute().fullPath;
  if (path[path.length - 1] == '/') {
    path = path.slice(0, -1);
  }

  const { data: content } = await useAsyncData('post', () => queryContent()
    .where({ _path: path })
    .findOne());

  const post = content.value;
  if (!post) {
    throw createError({ status: 404, statusText: "Post not found" });
  }
</script>

We can use the useRoute().fullPath property to read the URL path and fetch the blog post with a matching _path using the where() method.

<ContentRenderer :value="post" /> will load the body of the post into our page. This content is generated by nuxt/content turning our markdown files into HTML code.

If we go to localhost:3000/my-first-post we should be able to see the contents of our content/my-first-post.md file renderized in HTML.

blog-content

The blog post content

And that's it, you have a functioning blog site with an index and a way to display each blog post with generated routes. Now we can move on to generating our website's sitemap and feed.

Configuring the sitemap

Let's create a file named server/routes/sitemap.xml.ts, here we will use serverQueryContent to access the generated routes for each one of our blog posts and add them to the sitemap routes.

import { serverQueryContent } from '#content/server'
import { SitemapStream, streamToPromise } from 'sitemap'

export default defineEventHandler(async (e) => {
  const baseUrl = process.env.BASE_URL ?? '';

  const content = await serverQueryContent(e)
    .only(['_path'])
    .find();

    const sitemap = new SitemapStream({
    hostname: baseUrl
  })

  // Add nuxt/content routes
  for (const post of content) {
    sitemap.write({
      url: post._path,
      changefreq: 'weekly'
    })
  }

  // Add static routes
  sitemap.write({
    url: baseUrl,
    changefreq: 'weekly'
  })

  sitemap.end()

  setResponseHeader(e, "Content-Type", "application/xml");
  return streamToPromise(sitemap)
})

Open nuxt.config.ts and add the following configurations:

export default defineNuxtConfig({
  nitro: {
    prerender: {
      routes: ['/sitemap.xml'],
    },
  },
  ...
})

The sitemap will be available at base_url/sitemap.xml

nuxt-sitemap

The website's sitemap

Configuring the RSS feed

Let's create a file named server/api/feed.ts, here we will declare a function that uses serverQueryContent to return an RSS feed with the routes for our blog posts.

import type { H3Event } from 'h3'
import { Feed } from 'feed'
import { serverQueryContent } from '#content/server'

export async function getFeed(e: H3Event): Promise<Feed> {
  const baseUrl = process.env.BASE_URL ?? '';

  const content = await serverQueryContent(e)
    .only(['_path', 'title', 'summary', 'createdAt'])
    .find();

  const feed = new Feed({
    id: baseUrl,
    title: "DavidServn's Blog",
    description: "DavidServn's Blog Description",
    link: baseUrl,
    language: "en",
    image: baseUrl + "/images/bg.png",
    favicon: baseUrl + "/favicon-32x32.png",
    copyright: "All rights reserved, David Servin",
    feedLinks: {
      rss: baseUrl + '/feed.xml',
      json: baseUrl + '/feed.json',
      atom: baseUrl + '/feed.atom',
    },
    author: {
      name: "David Servin",
      email: '[email protected]',
      link: 'https://www.davidservn.com/',
    },
  });

  content.forEach((post) => {
    const url = `${baseUrl}${post._path}`;
    feed.addItem({
      title: post.title ?? '',
      id: url,
      link: url,
      description: post.summary,
      date: new Date(post.createdAt),
      image: baseUrl + "/images/blog/meta" + post._path + ".png",
      author: [{
        name: "David Servin",
        email: '[email protected]',
        link: 'https://www.davidservn.com/',
      }],
    })
  });

  return feed;
}

Now let's create the routes for each of the feed files.

server/routes/feed.atom.ts

import { getFeed } from '../api/feed'

export default defineEventHandler(async (e) => {
  setResponseHeader(e, "Content-Type", "application/xml");
  return (await getFeed(e)).atom1();
})

server/routes/feed.json.ts

import { getFeed } from '../api/feed'

export default defineEventHandler(async (e) => {
  setResponseHeader(e, "Content-Type", "application/json");
  return (await getFeed(e)).json1();
})

server/routes/feed.xml.ts

import { getFeed } from '../api/feed'

export default defineEventHandler(async (e) => {
  setResponseHeader(e, "Content-Type", "application/xml");
  return (await getFeed(e)).rss2();
})

Open nuxt.config.ts and add the following configurations:

export default defineNuxtConfig({
  nitro: {
    prerender: {
      routes: ['/sitemap.xml', '/feed.atom', '/feed.json', '/feed.xml'],
    },
  },
  ...
})

The feeds are going to be available at the specified paths base_url/feed.atom base_url/feed.json base_url/feed.xml

nuxt-feed

The website's RSS feed

GitHub repository

In order to host our site we will first need to upload our code to a GitHub or GitLab repository in order to connect it with Cloudflare Pages and have our site automatically deployed.

Since every repository is different and we don't need any special configuration I think it's better for you to follow an official guide, here is one for GitHub.

Configuring hosting with Cloudflare Pages

Now we can move on to hosting our site on the internet for free. We can very easily connect our GitHub repository to Cloudflare Pages and whenever we send changes to our main branch our site will be automatically deployed.

  1. Go to your Cloudflare dashboard.
  2. Click on Pages.
  3. Click on Create a project and Connect to Git.
  4. Add your GitHub account and select your repository.
  5. Select Nuxt.js under Framework preset.
  6. Make sure the Build command is nuxt generate.
  7. Under Environment variables (advanced) make sure to add your variables from your .env file.
  8. Click on Save and deploy and your website will start to build.
cloudflare-config

Example of the build configuration

If you forgot to define the environment variables you can configure them like this:

  1. Go to the Settings tab of your Cloudflare Pages project.
  2. Click on Environment variables.
  3. Add the variables from the .env file.
cloudflare-env-config

Example of the environment variables configuration

Note: If your deployment fails during npm install you might need to add the following to your package.json:

"optionalDependencies": {
  "@rollup/rollup-linux-x64-gnu": "4.9.5"
}

Once your site successfully builds it should automatically be deployed to the assigned free domain project-name.pages.dev and your site is now live on the internet for free!

Adding SEO to your website

Now that we have a website on the internet we can add some metadata known as SEO in order to improve our online presence and search engine rankings.

There are multiple SEO experts online with different tips and tricks, there's a whole world out there for you to explore. To keep this brief I'll only show you the bare minimum to get you started.

robots.txt

This file will help with the discoverability of your site by search engine crawlers.

Create a file named robots.txt inside the static folder with the following content:

User-agent: *
Disallow:

Open Graph tags

Open Graph tags make your site pop more when it is linked on social media. Think of those posts on Facebook or Twitter that shows you a linked website with an image and a headline.

Open nuxt.config.ts and add the following metadata with your website's information:

export default defineNuxtConfig({
  app: {
    // https://nuxt.com/docs/getting-started/seo-meta
    head: {
      title: "DavidServn's Blog",
      htmlAttrs: {
        lang: "en",
      },
      meta: [
        { charset: "utf-8" },
        {
          name: "viewport",
          content: "width=device-width, initial-scale=1, user-scalable=no",
        },
        {
          hid: "robots",
          name: "robots",
          content: "index,follow",
        },
        {
          hid: "googlebot",
          name: "googlebot",
          content: "index,follow",
        },
        {
          hid: "description",
          name: "description",
          content: "This is my blog.",
        },
        { hid: "og:title", name: "og:title", content: "DavidServn's Blog" },
        {
          hid: "og:description",
          name: "og:description",
          content: "This is my blog.",
        },
        { hid: "og:url", name: "og:url", content: process.env.BASE_URL },
        { hid: "og:type", name: "og:type", content: "website" },
        {
          hid: "og:image",
          name: "og:image",
          content: process.env.BASE_URL + "/images/bg.png",
        },
        {
          hid: "og:image:alt",
          name: "og:image:alt",
          content: "DavidServn's Blog",
        },
        { hid: "og:image:width", name: "og:image:width", content: "2449" },
        { hid: "og:image:height", name: "og:image:height", content: "1632" },
        {
          hid: "twitter:card",
          name: "twitter:card",
          content: "summary_large_image",
        },
        { hid: "twitter:site", name: "twitter:site", content: "@davidservn" },
        {
          hid: "twitter:creator",
          name: "twitter:creator",
          content: "@davidservn",
        },
        {
          hid: "theme-color",
          name: "theme-color",
          content: "#1b1f22",
        },
      ],
      link: [
        {
          hid: "canonical",
          rel: "canonical",
          href: process.env.BASE_URL,
        },
        { rel: "icon", type: "image/x-icon", href: "/favicon.ico" },
      ],
    },
  },
})

You can go to this site to test your Open Graph tags. Add the URL of your site and the information of your website should appear on the preview.

open-graph-meta-tags-1

Example of Open Graph meta tags for the blog site

We have added the general information of our site but we should override that information when we link a blog post directly with its own information.

We first need to add our environmental variables to the client-side rendering. We need to open nuxt.config.ts and add the following configuration:

export default defineNuxtConfig({
  runtimeConfig: {
    public: {
      BASE_URL: process.env.BASE_URL
    }
  },
  ...
})

Since we have a page for displaying the post's content we only need to open pages/[...slug].vue and add the following metadata:

<script setup lang="ts">
  const config = useRuntimeConfig()

  useHead({
    title: post.title,
    meta: [
      {
        hid: "og:title",
        name: "og:title",
        content: post.title
      },
      {
        hid: "og:image:alt",
        name: "og:image:alt",
        content: post.title,
      },
      {
        hid: "description",
        name: "description",
        content: post.summary,
      },
      {
        hid: "og:description",
        name: "og:description",
        content: post.summary,
      },
      {
        hid: "og:url",
        name: "og:url",
        content: config.public.BASE_URL + post._path,
      },
      {
        hid: "og:type",
        name: "og:type",
        content: "article",
      },
      {
        hid: "article:published_time",
        name: "article:published_time",
        content: post.createdAt,
      },
      {
        hid: "og:image",
        name: "og:image",
        content: config.public.BASE_URL + "/images/blog/meta/" + post.slug + ".png",
      },
      {
        hid: "og:image:width",
        name: "og:image:width",
        content: "1350",
      },
      {
        hid: "og:image:height",
        name: "og:image:height",
        content: "900",
      },
    ],
    link: [
      {
        hid: "canonical",
        rel: "canonical",
        href: config.public.BASE_URL + post._path,
      },
    ],
  });
  ...
</script>

If you need more data per post you can add custom fields in the header of the content markdown files like this:

---
myCustomField: Hello
---

And we are able to access that field on the pages/_.vue file like this post.myCustomField.

If we now load a URL of a blog post like https://davidservn-jamstack-blog-sample.pages.dev/poem-dark-sight we should see the post's information on the preview.

open-graph-meta-tags-2

Example of Open Graph meta tags for an individual blog post

Adding Dependabot to your repository

Dependabot is a very useful GitHub bot that will automatically create pull requests for your project whenever a dependency receives an update. This will ensure that your project is always up to date.

If you want to configure dependabot for your project you'll need to create a folder called .github on the root of your project and inside create the following dependabot.yml file:

version: 2
updates:
  # Fetch and update latest `npm` packages
  - package-ecosystem: npm
    directory: '/'
    schedule:
      interval: monthly
      time: '00:00'
    open-pull-requests-limit: 10
    reviewers:
      - YOUR_GITHUB_USERNAME_HERE
    assignees:
      - YOUR_GITHUB_USERNAME_HERE
    commit-message:
      prefix: fix
      prefix-development: chore
      include: scope

Note: Make sure to replace YOUR_GITHUB_USERNAME_HERE with your GitHub username.


Footnotes

  1. Please consult the pricing for more accurate information https://pages.cloudflare.com/#pricing.

Follow me on Twitter to be notified about new posts or through RSS ( XML / JSON / ATOM ).

X