Logo

Static server rendering with Next.js

In our current version of MaffinBlog we have a function getPosts.tsx that retrieves all Github issues that we want to display as blog posts in our Blog. However, the function currently executes at request time and the first time that gets called is quite costly due to network latency and markdown parsing that happens in the background. We want to exploit generateStaticParams from Next.js so all our pages are generated before-hand!

In this post we will

  • ⚡ Make your Blog lightning fast by pre-rendering everything at build time.
  • 🏷️ Generate tag pages for better discoverability
  • ➕ Bonus point: Since our blog is now fully static, we will be deploying it in Github pages!

⚡ Static rendering of our posts

Currently we retrieve the post in our PostDetailPage component like this:

src/app/posts/[slug]/page.tsx
export default async function PostDetailPage(
  { params: { slug } }: { params: { slug: string } },
): Promise<JSX.Element> {
  const post = await getPost(slug);

  if (post === null) {
    notFound();
  }

  return([...]);

which currently renders at request time. To prove that, in the terminal you can run yarn build and see that Next.js didn't render your blog page (you would see /posts/<your-slug> if it would).

Route (app)                                Size     First Load JS
┌ ○ /                                      175 B          81.7 kB
└ λ /posts/[slug]                          430 B          86.9 kB
[...]
(Static)  automatically rendered as static HTML (uses no initial props)
(SSG)     automatically generated as static HTML + JSON (uses getStaticProps)

This is because Next.js has no information at build time of which blog post slugs exist. In order to give this information at build time, you need to use generateStaticParams function which generate as many pages as different slugs exist.

Note that static rendering at build time has the caveat of needing a re-deploy when you create a new issue or update one. We are okay with this as we want the speed that comes with this and we can workaround this using github hooks

This is our generateStaticParams for our blog details page:

src/app/posts/[slug]/page.tsx
export async function generateStaticParams() {
  const posts = await getPosts();

  return posts.map((post) => ({
    slug: post.slug,
  }));
}

export default async function PostDetailPage(
  { params: { slug } }: { params: { slug: string } },
): Promise<JSX.Element> {
  const post = await getPost(slug);

  if (post === null) {
    notFound();
  }

  return([...]);

And now, if you run yarn build you'll see that the page generated as expected:

Route (app)                                    Size     First Load JS
┌ ○ /                                          175 B          81.7 kB
└ ● /posts/[slug]                              430 B          86.9 kB
    └ /posts/github-issues-as-nextjs-blog-cms
[...]
(Static)  automatically rendered as static HTML (uses no initial props)
(SSG)     automatically generated as static HTML + JSON (uses getStaticProps)

Even after doing this, dev mode renders your page at request time. To make sure it works you should be checking yarn build output.

It's important to know that generateStaticParams as its name says it only generates static params. In your component you still have to write code to retrieve your object and parse it as expected. This helps to keep compatibility between different approaches of rendering your components.

🏷️ Tag pages

Using the approach above we can also do the same for our tags pages. Let's say we want to have something like /tags/[tag] and then render a the list of blog posts that for that specific tag. Let's start by creating a file in src/app/tags/[tag]/page.tsx with the contents:

src/app/tags/[tag]/page.tsx
import React from 'react';
import Link from 'next/link';

import { getPosts, getPostsByTag } from '@/app/api/getPosts';

export async function generateStaticParams() {
  const posts = await getPosts();
  const tags = new Set<string>();

  posts.forEach((post) => tags.forEach((tag) => tags.add(tag)));

  return Array.from(tags).map((tag) => ({
    tag,
  }));
}

export default async function TagPage(
  { params: { tag } }: { params: { tag: string } },
): Promise<JSX.Element> {
  const posts = await getPostsByTag(tag);

  return (
    <>
      {posts.map((post) => (
        <article key={post.slug}>
          <h2 className="text-2xl font-bold leading-8 tracking-tight">
            <Link
              href={`/posts/${post.slug}`}
              className="text-gray-900 dark:text-gray-100"
            >
              {post.title}
            </Link>
          </h2>
        </article>
      ))}
    </>
  );
}

And the new getPostsByTag function:

export async function getPostsByTag(tag: string): Promise<Post[]> {
  const posts = await getPosts();
  return posts.filter((post) => post.tags.includes(tag));
}

Running yarn build now will show you that it detects the new tag pages as static ones:

┌ ○ /                                          178 B          81.7 kB
├ ● /posts/[slug]                              430 B          86.9 kB
├   ├ /posts/static-server-rendering-nextjs
├   └ /posts/github-issues-as-nextjs-blog-cms
└ ● /tags/[tag]                                178 B          81.7 kB
    ├ /tags/next.js
    ├ /tags/engineering
    └ /tags/octokit

➕ Deploying our static page to github pages

I want Maffin Blog infrastructure to be as simple as possible and that means no servers. Thus it means the blog has to be fully static which is what we've been working on in the previous pages. Doing this allows us to host our site in any hosting that is able to serve static content and our choice is... well Github Pages!

In order to support that, you need to add the following to next.config.js:

  images: {
    unoptimized: true, // image optimization is not supported with export
  },
  output: 'export', // tells the compiler to generate the `out` folder with the static content

By doing this, we loose some built in functionality from Next.js. The docs about static export are really good explaining the tradeoffs.

This is all we need really, now to test it you can run again yarn build and at the end you should see an out folder at the root of your project that contains all the static content.

Next step is to configure Github so it hosts your blog. The first step is to add a new workflow configuration so it builds and deploys it (extracted from github pages examples for Next.js):

Let's start with adding a new workflow configuration that looks like this (extracted from github pages examples for Next.js):

.github/workflows/deploy.yml
# Sample workflow for building and deploying a Next.js site to GitHub Pages
#
# To get started with Next.js see: https://nextjs.org/docs/getting-started
#
name: Deploy Maffin Blog site to Pages

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

  # 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.
# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
concurrency:
  group: "pages"
  cancel-in-progress: false

jobs:
  # Build job
  build:
    runs-on: ubuntu-latest

    strategy:
      matrix:
        node-version: [16.17.0]

    steps:
      - name: Checkout
        uses: actions/checkout@v3

      - name: Setup Node
        uses: actions/setup-node@v3
        with:
          node-version: ${{ matrix.node-version }}
          cache: 'yarn'

      - name: Setup Pages
        uses: actions/configure-pages@v3
        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@v3
        with:
          path: |
            .next/cache
          # Generate a new cache whenever packages or source files change.
          key: ${{ runner.os }}-nextjs-${{ hashFiles('**/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('**/yarn.lock') }}-

      - name: Install dependencies
        run: yarn

      - name: Build with Next.js
        run: yarn build

      # Only needed if you are using a custom domain
      - name: Set CNAME file for proper static asset loading
        run: echo '<your custom domain>' > ./out/CNAME

      - name: Upload artifact
        uses: actions/upload-pages-artifact@v1
        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@v2

Note that we are deploying the static_rendering branch. You can update if needed and when you've proven it works, make sure to set master. Before pushing though, you'll need to navigate to settings -> environments in your github repo and make sure you add a new rule for your branch (deployment branches).

After this, you can push your branch and you'll see the workflow in action:

Screenshot 2023-04-26 at 22 45 46

Once succeeded visit the URL and you should see your blog!

💚 Conclusion

This post demonstrates how versatile Nextjs is by allowing us to just generate a bunch of static files to be hosted wherever. Not only this but we can still have dynamic behavior client side if needed. Also, Github is awesome and makes it very easy to deploy with workflows as you can see!

Feel free to navigate the code freely in here. It refers to the commit I used to ship the changes described here and you can find unit tests for the functionality added too and it adds some extra code for formatting the tag pages with Tailwind plus some refactors to Post list pages.

That's it! If you enjoyed the post feel free to react to it in the Github tracker (for now).