The personal blog of Scott Polhemus

Building a custom blog website for free using Next.js, WordPress.com, and GitHub Pages

Wednesday, May 1st 2024

I recently decided to start writing more, so naturally one of my first steps in procrastinating from actually doing so was to figure out how to set up a custom website for my blog in 2024. While I could easily spin up an account on one of the many platforms that exist these days which allow for “no-code” online publishing, where’s the fun in that? I’m a software developer by trade, and if there’s one thing we’re great at it’s taking a simple problem and heavily over-engineering a solution.

In all seriousness, there are plenty of reasons why you might want to build your own website from scratch rather than picking something up “off the shelf”. It might be a learning experience, a hobby project, or a means to take complete ownership and control over how your written work is presented to readers online. For myself, as I attempt to document more of my experiences in a new way, it’s a bit of all of the above.

Platform and Architecture

I approached this project with the following goals in mind:

  • Use a modern frontend tech stack (which, let’s be honest, basically means “React and Typescript” these days), and try to keep it simple
  • Compose and manage posts in an external, fully featured content management system (I’d rather not host my own CMS or mess with any finicky filesystem-based approaches)
  • Host the site at little to no cost, while making it easy to push new updates as I develop the site and publish new content

Why Next.js?

There are a few React frameworks out there with great features and community support, but I’ve been partial to Next.js after a few good experiences with it early on. I remember pushing Casper’s tech team to ditch their self-maintained React server-side-rendering solution in favor of Next.js as early as 2017. (We did eventually start shipping much of the site via a Next.js PWA, a transition that led to great improvements in developer productivity and site performance.)

Next.js is a robust and flexible framework that prioritizes the developer experience and gives you a lot of options for how you choose to deploy your site. Since I’m aiming to keep costs low for my personal blog, I’ll be using Next.js as a “static site generator” to compile my site pages ahead of time, rather than running a live web server to handle requests on demand. (More on that in a bit.)

Why WordPress.com?

I’ll be using a CMS in a “headless” capacity, since I want to manage my content there and build my own frontend site. For convenience and cost reasons, I ended up returning to the old standby of the CMS world, WordPress. In a past life, I was primarily a PHP developer working mostly on custom WordPress instances, so I know that the platform is a solid choice with support for just about every feature one could ask for in a CMS.

WordPress also happens to have a pretty great REST API built in, which is thankfully available for use from even the lowest-tier (read: free) instances on the fully managed version of the platform available from WordPress.com. That means we can spin up a free account and immediately start using it as the content source for a decoupled frontend just by implementing a couple API requests. Score!

(I’ve seen some tutorials online that recommend the WPGraphQL plugin for headless integrations. GraphQL is certainly trendier than REST as an API schema, and it does enable a certain amount of optimization for complex views, but with the trade-off of greater technical complexity that doesn’t pay off for many projects. We’re also not able to install third-party plugins on our free WordPress.com site, so we’ve got no choice but to rely on the REST API built into the core platform.)

Why GitHub Pages?

Many platforms these days offer static hosting for free (among their other services), since static websites require much fewer resources vs. sites operating on “dynamic” web servers. I’ve had a personal webpage hosted on my own domain using GitHub Pages (the free static hosting service built into GitHub) for several years, so it was a natural choice for this project.

GitHub Pages a convenient way to deploy simple static HTML websites for project documentation, personal homepages, or anything else. For more complex use-cases, it’s possible to configure a workflow using GitHub Actions so that the static site can still be generated from dynamic data sources by running the workflow on GitHub’s servers.

Getting Started

Set up a blog on WordPress.com

The first thing to do is sign up for a free blog on WordPress.com. At some point in the new user onboarding flow, you’ll be asked to select a domain for your site. Since we’re not using WordPress as the frontend this doesn’t matter to us, so we can go with the free “[blog].wordpress.com” option and enter any blog name you like. (Take note of the domain that you select, we’ll need to reference it later when we integrate with the WordPress API.)

There are a bunch of customization options which mostly affect the built-in frontend that we can skip right past, since we won’t be using it. There’s also a “launch my site” button near the end of the flow that we can conveniently leave un-clicked to keep the WordPress site hidden from the public. I was pleased to see that REST API access is enabled even when the site is in its “not launched” state.

Create a repository on GitHub

If you’re following along and you don’t have one already, you’ll need to sign up for an account on GitHub.com. A free account will be sufficient for our needs here as well.

Now we’ll create a repo for our project. The name of your repository will determine the URL for the project’s GitHub Pages site. Unless you configure a custom domain, a repository with the name [username].github.io will automatically deploy its GitHub Pages to your personal github.io subdomain, and other projects under your account will get GitHub Pages sites under a sub-path of that domain (so, for instance, a repository called blog would deploy its GitHub Pages instance to [username].github.io/blog).

Start a new website project with Next.js

Following along with the current documentation for Next.js, our first step is to generate a project in our local development environment using the create-next-app generator. It asks a few questions which I left on the default options, shown below:

$ npx create-next-app@latest
✔ What is your project named? … my-wp-blog
✔ Would you like to use TypeScript? … Yes
✔ Would you like to use ESLint? … Yes
✔ Would you like to use Tailwind CSS? … Yes
✔ Would you like to use `src/` directory? … No
✔ Would you like to use App Router? (recommended) … Yes
✔ Would you like to customize the default import alias (@/*)? … No

This will generate a working Next.js-based full stack React web application for us. It’s particularly nice that the generator includes Tailwind, a popular utility class framework which will allow us to build out our interface without writing much custom CSS.

Integrating with the WordPress REST API

We’ll focus on building the two essential views for a blog website: the index view where we list out recent posts, usually on the homepage route; and the individual post detail view, where we can read a full article without distractions. The API endpoint for fetching posts from a WordPress blog is open to unauthenticated requests by default, so we’ll be able to integrate this content without any additional setup on the backend.

Adding a listing of recent posts to the home page

Starting with the main “index” route, we’ll want to fetch and display a list of recent posts from our blog. When using the app router (which you should be if you’re following along), you can make asynchronous data requests directly within your React components, making this a fairly simple task. We’ll remove the boilerplate content from the app/page.tsx file which represents the index view, and replace it with an async component which fetches and displays the recent posts:

// app/page.tsx

export default async function Home() {
  const posts = await fetch(
    `https://public-api.wordpress.com/wp/v2/sites/${process.env.WORDPRESS_COM_DOMAIN}/posts`
  ).then((res) => res.json())

  return (
    <div>
      <h1>Recent Posts</h1>
      <ul>
        {posts.map((post, index) => (
          <li key={`post-${index}`}>
            <h2>{post.title.rendered}</h2>
            <div dangerouslySetInnerHTML={{ __html: post.excerpt.rendered }}></div>
          </li>
        ))}
      </ul>
    </div>
  )
}

To break this down further, our Home component is requesting posts from the WordPress REST API, converting the JSON response to a parsed array of objects, and rendering an unordered list element containing the post titles and excerpts. (Note that content intended for display is usually contained under “rendered” sub-properties in the REST API response, and that dangerouslySetInnerHTML is necessary to properly display HTML content such as excerpts and post content from the API.)

Also worth pointing out: the request URL is constructed with a template string referencing an environment variable, WORDPRESS_COM_DOMAIN. To get that to work, create a .env.local file and populate it with your blog’s domain so that the component knows where to get the post content from:

// .env.local

WORDPRESS_COM_DOMAIN=[blog].wordpress.com

Since we’re using TypeScript, we’ll also want to document an interface for the WordPress post data retrieved from the API. Create a file called types/index.tsx under your project root and define the type like so:

// types/index.tsx

export interface WordPressPost {
  slug: string
  title: {
    rendered: string
  }
  date: string
  excerpt: {
    rendered: string
  }
  content: {
    rendered: string
  }
}

(To keep it short, I’ve documented only the attributes that I’m using at the moment rather than the full schema.)

Then, update the Home component to assign the correct type to the API response. We need to indicate that the parsed JSON response will be an array of WordPressPost objects which can be done like this:

// app/page.tsx

import { WordPressPost } from '@/types'

export default async function Home() {
  const posts = (await fetch(
    `https://public-api.wordpress.com/wp/v2/sites/${process.env.WORDPRESS_COM_DOMAIN}/posts`
  ).then((res) => res.json())) as WordPressPost[]

  return (
    // ... (same as above)
  )
}

Creating a route for individual post details

Next, we’ll create a “detail” route for displaying individual posts. Using Next.js path parameters, we can do this by creating a file at app/posts/[slug]/page.tsx with the following:

// app/posts/[slug]/page.tsx

import type { WordPressPost } from '@/types'

export default async function Post({
  params,
}: {
  params: { slug: string }
}) {
  const [post] = (await fetch(
    `https://public-api.wordpress.com/wp/v2/sites/${process.env.WORDPRESS_COM_DOMAIN}/posts?slug=${params.slug}`
  ).then((res) => res.json())) as WordPressPost[]

  return (
    <div>
      <h1>{post.title.rendered}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.content.rendered }}></div>
    </div>
  )
}

By adding a slug query to the posts API request, we can look up a single post by its URL slug and display its content on the page.

Adding links between pages

To make the site a bit more useful, let’s add links to allow visitors to navigate between our two views.

To start, we can update the index view to link out to our new post detail pages like this:

// app/page.tsx

import Link from 'next/link'
import { WordPressPost } from '@/types'

export default async function Home() {
  const posts = (await fetch(
    `https://public-api.wordpress.com/wp/v2/sites/${process.env.WORDPRESS_COM_DOMAIN}/posts`
  ).then((res) => res.json())) as WordPressPost[]

  return (
    <div>
      <h1>Recent Posts</h1>
      <ul>
        {posts.map((post, index) => (
          <li key={`post-${index}`}>
            <h3>
              <Link href={`/posts/${post.slug}`}>
                {post.title.rendered}
              </Link>
            </h3>
            <div dangerouslySetInnerHTML={{ __html: post.excerpt.rendered }}></div>
          </li>
        ))}
      </ul>
    </div>
  )
}

Here we’ve just imported the Link component from Next.js, and used it to make the post titles clickable to navigate to our post detail view.

It might also be helpful to allow site visitors to easily navigate back to the homepage. This is commonly done by linking the site title at the top of every page, so we can add a heading with another Link to our app/layout.tsx file:

// app/layout.tsx

import Link from 'next/link'
import './globals.css'

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode
}>) {
  return (
    <html lang="en">
      <body>
        <header>
          <h1>
            <Link href="/">Next WordPress Blog</Link>
          </h1>
        </header>
        {children}
      </body>
    </html>
  )
}

Applying visual styles

At this point, we have all of the necessary content structure built out, but everything is completely unstyled. Because Tailwind applies a CSS reset, we don’t even have a visual hierarchy between heading elements!

Using utility classes for margin, padding, and typography, we can start to create some layout and hierarchy on our pages. For instance, to make the post title stand out on the detail page we might do something like this:

<h1 className="mb-4 font-serif text-5xl">{post.title.rendered}</h1>

mb-4 adds margin below the element, font-serif adjusts the font and text-5xl applies one of the larger text size options from Tailwind.

We’ll also need to apply styles to the rendered content coming out of WordPress. This will be coming through as HTML strings, so we can’t apply Tailwind classes to elements directly. Instead, we’ll add a custom CSS class (which I like to call rich-text) to each element containing WordPress-generated HTML. We can the define rules in our globals.css file to apply styling to the content. I’m using Tailwind’s @apply syntax to keep the approach consistent with how we’re styling individual elements. Here’s what I came up with as a basic set of essential styles:

/* app/globals.css */

/* Tailwind CSS layers */
@tailwind base;
@tailwind components;
@tailwind utilities;

/* Styles for "rich text" content produced in WordPress editor */
.rich-text {
  p,
  h1,
  h2,
  h3,
  h4,
  h5,
  h6,
  ul,
  ol,
  blockquote {
    @apply mb-4;
  }

  h1 {
    @apply font-serif text-5xl;
  }

  h2 {
    @apply font-serif text-4xl;
  }

  h3 {
    @apply font-serif text-3xl;
  }

  h4 {
    @apply font-serif text-2xl;
  }

  h5 {
    @apply font-serif text-xl;
  }

  h6 {
    @apply font-serif text-lg;
  }

  ul {
    @apply list-inside list-disc;
  }

  ol {
    @apply list-inside list-decimal;
  }

  blockquote {
    @apply p-4 font-serif text-2xl italic;
  }

  a {
    @apply text-blue-600 underline hover:text-blue-400;
  }

  /* WordPress editor formatting classes */

  .has-text-align-left {
    @apply text-left;
  }

  .has-text-align-right {
    @apply text-right;
  }

  .has-text-align-center {
    @apply text-center;
  }

  .has-small-font-size {
    @apply text-sm;
  }

  .has-medium-font-size {
    @apply text-base;
  }

  .has-large-font-size {
    @apply text-2xl;
  }

  .has-x-large-font-size {
    @apply text-4xl;
  }

  .has-xx-large-font-size {
    @apply text-5xl;
  }
}

After applying the class to our post content wrapper, the detail view route file looks like this:

// app/posts/[slug]/page.tsx

import type { WordPressPost } from '@/types'

export default async function PostDetail({
  params,
}: {
  params: { slug: string }
}) {
  const [post] = (await fetch(
    `https://public-api.wordpress.com/wp/v2/sites/${process.env.WORDPRESS_COM_DOMAIN}/posts?slug=${params.slug}`
  ).then((res) => res.json())) as WordPressPost[]

  return (
    <div className="p-4">
      <h1 className="mb-4 font-serif text-5xl">{post.title.rendered}</h1>
      <div
        className="rich-text"
        dangerouslySetInnerHTML={{ __html: post.content.rendered }}
      />
    </div>
  )
}

With that, we’ve got a decent-looking site displaying the main content from our WordPress blog! All that’s left now is to set up the workflow for deploying the site to GitHub pages and we’ll have ourselves a shiny new website.

Deploying to GitHub Pages

Configure Next.js for static output

The first thing we need to do before we can deploy our site is to configure Next.js for static site generation. By default, Next.js produces a Node app that you can run on a server to host your site dynamically. If you want to export the site for a static host, you need to update your next.config.mjs file to turn the feature on:

// next.config.mjs

/** @type {import('next').NextConfig} */
const nextConfig = {
  output: 'export',
};

export default nextConfig;

There’s one more thing: because we’re pre-rendering the site as static files, Next.js needs to know at build-time what all of the possible routes are that would have pages under the post detail view. For this, we can add a generateStaticParams function export to the post detail’s page.tsx file. This function requests the posts list and returns an array of path parameters to represent each known post.

// app/posts/[slug]/page.tsx

import type { WordPressPost } from '@/types'

export async function generateStaticParams() {
  const posts = (await fetch(
    `https://public-api.wordpress.com/wp/v2/sites/${process.env.WORDPRESS_COM_DOMAIN}/posts`
  ).then((res) => res.json())) as WordPressPost[]

  return posts.map(({ slug }) => ({ slug }))
}

export default async function PostDetail({
  params,
}: {
  params: { slug: string }
}) {
  // ... (same as above)
}

Add a GitHub Actions workflow to build and deploy the site

GitHub Actions makes it pretty simple to add a workflow to build and deploy the Next.js static website to GitHub Pages. Once you’ve pushed your code to the remote GitHub repository, you can use the UI to browse for, customize, and add the workflow to your project. Otherwise, you can just add this file at .github/workflows/nextjs.yml:

# .github/workflows/nextjs.yml

name: Deploy Next.js site to Pages

on:
  # Runs on pushes targeting the default branch
  push:
    branches: ["main"]

  # Allows you to run this workflow manually from the Actions tab
  workflow_dispatch:

# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
permissions:
  contents: read
  pages: write
  id-token: write

# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
# Cancel in-progress runs as we only want to deploy the most recent code + content to production.
concurrency:
  group: "pages"
  cancel-in-progress: true

jobs:
  # Build job
  build:
    environment: github-pages
    runs-on: ubuntu-latest
    env:
      WORDPRESS_COM_DOMAIN: ${{ vars.WORDPRESS_COM_DOMAIN }}
    steps:
      - name: Checkout
        uses: actions/checkout@v4
      - name: Detect package manager
        id: detect-package-manager
        run: |
          if [ -f "${{ github.workspace }}/yarn.lock" ]; then
            echo "manager=yarn" >> $GITHUB_OUTPUT
            echo "command=install" >> $GITHUB_OUTPUT
            echo "runner=yarn" >> $GITHUB_OUTPUT
            exit 0
          elif [ -f "${{ github.workspace }}/package.json" ]; then
            echo "manager=npm" >> $GITHUB_OUTPUT
            echo "command=ci" >> $GITHUB_OUTPUT
            echo "runner=npx --no-install" >> $GITHUB_OUTPUT
            exit 0
          else
            echo "Unable to determine package manager"
            exit 1
          fi
      - name: Setup Node
        uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: ${{ steps.detect-package-manager.outputs.manager }}
      - name: Setup Pages
        uses: actions/configure-pages@v5
        with:
          # Automatically inject basePath in your Next.js configuration file and disable
          # server side image optimization (https://nextjs.org/docs/api-reference/next/image#unoptimized).
          #
          # You may remove this line if you want to manage the configuration yourself.
          static_site_generator: next
      - name: Restore cache
        uses: actions/cache@v4
        with:
          path: |
            .next/cache
          # Generate a new cache whenever packages or source files change.
          key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json', '**/yarn.lock') }}-${{ hashFiles('**.[jt]s', '**.[jt]sx') }}
          # If source files changed but packages didn't, rebuild from a prior cache.
          restore-keys: |
            ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json', '**/yarn.lock') }}-
      - name: Install dependencies
        run: ${{ steps.detect-package-manager.outputs.manager }} ${{ steps.detect-package-manager.outputs.command }}
      - name: Build with Next.js
        run: ${{ steps.detect-package-manager.outputs.runner }} next build
      - name: Upload artifact
        uses: actions/upload-pages-artifact@v3
        with:
          path: ./out

  # Deployment job
  deploy:
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    runs-on: ubuntu-latest
    needs: build
    steps:
      - name: Deploy to GitHub Pages
        id: deployment
        uses: actions/deploy-pages@v4

The only significant change I’ve made here from the default workflow file is to add the env block under the build job to pass through our WORDPRESS_COM_DOMAIN value. (You’ll need to add this value to your project settings in GitHub as a variable under the github-pages environment.)

Commit and push this workflow action to your repo’s main branch and you should see an action kick off on your repository with the first deploy of your new blog!

Next Steps

If you’re reading this on my blog at https://polhem.us, you’re looking at a slightly more built-out version of this very starter application. For anyone who’d like to take the concept and make it their own, I’ve created a repository on GitHub to share the basic, barebones starter application here. My personal site is also open-source under its own repository here.

I hope this has been interesting and informative, it was fun for me to put together and I’m excited to do even more with my new site!