Always Start with an Issue

How to Build a Blog with GitLab Issues and Next.js

I want to preface by saying thank you for reading this issue. Understandably, it can be jarring to imagine why one would use issues to write a blog post. Why not just use a proper blogging platform? The answer, admittedly, is that I am obsessed with issues, and have been since I started working at GitLab. Issues are a powerful avenue for communication, and always starting with an issue is a routine that amplifies shared understanding, discovery, and collaboration. I use them to immortalize everything in my work. Was something discussed in Slack, on a call, or over email? I record that information into an issue. I was extraordinarily curious to see if I can use the GitLab Issues API to bring the life of an issue to a new place; my blog. When Next.js v9.3 was released with static site generation support, I was sparked with interest to combine GitLab Issues with Next.js SSG. In this issue, I will demonstrate how to use GitLab Issues and Next.js to build a statically generated blog.

Big Picture

The end result of this guide will be a static html blog generated by Next.js with the blog content being sourced from GitLab Issues, similar to what you see right here! The only difference is that I won't be getting into styling or SEO, and that is to keep things simple and focused. I might go into detail of those topics in a future blog post. You can get a look at how I've built this blog by checking out the source code.

On self-hosting

Before starting this, I already had a personal GitLab instance running on brdn.dev, which I decided to continue to use as the blog host. You're welcome to make an account and post comments.

Should you also self-host if you want to follow along with this guide? That is up to you. We make installing GitLab really simple with Omnibus, but you'll need your own server with a recommended 4GB of RAM.

However, you should be able to follow along by using GitLab.com as your host as well.

Open an Issue

To get started, make a new project. We can call this project blog for example. After the project is created, create a new issue. This issue will be a blog post. Add some content, this can be anything for now just to get started and then save the issue.

Installing modules

We'll be installing the latest version of Next.js and React. We'll also use a handful of other npm modules, but don't let that scare you, it's mostly for nice-to-haves such as syntax highlighting, rendering emoji, formatting time, and the essential xss prevention. So lets just get that out of the way here:

$ npm i next@latest react-dom@latest react@latest gitlab @sindresorhus/slugify highlight.js marked dompurify emoji-toolkit strftime jsdom

Utils

First, we'll set up a common utility that allows Next.js to request content from the GitLab API at build time.

Make a utils folder to put the common gitlab.js util:

$ mkdir utils && touch utils/gitlab.js

Add the following in utils/gitlab.js:

const { Gitlab } = require("gitlab");

const token = "<your-access-token>";
const host = "https://gitlab.com"; // If you self-host, set this to your domain.

const api = new Gitlab({
  token,
  host
});

module.exports = api;

Note: If you don't have a GitLab access token, you can generate one by following the personal access tokens docs.

Making a home page

Make an index.js page:

$ mkdir pages && touch pages/index.js

In index.js, import the gitlab.js util that you've created, along with next/link to create links to your blog posts.

// index.js
import gitlab from "../utils/gitlab";
import Link from "next/link";

Create and export a default function that expects an array of issues as a prop.

// pages/index.js

import gitlab from "../utils/gitlab";
import Link from "next/link";
import slugify from "@sindresorhus/slugify";

export default ({ issues }) => {
  return (
    <ul>
      {issues.map(issue => (
        <li key={issue.iid}>
          <Link href="/issue/[title]" as={`/issue/${slugify(issue.title)}`}>
            <a>
              {issue.title}
            </a>
          </Link>
        </li>
      ))}
    </ul>
  );
};

Notice that we map over every issue, creating a dynamic Next.js Link to /issue/[title] where [title] is a URL slug of our issue title. For instance, always-start-with-an-issue is the slug of "Always Start with an Issue".

We still need to create the /issue/[title].js path in our Next.js project, so lets go ahead and do that:

$ mkdir pages/issue && touch pages/issue/[title].js

Getting Props at Build Time

We want Next.js to fetch our issues at build time, so that we can generate a static index.html file with our list of issues.

To do this, we'll use getStaticProps:

export const getStaticProps = async () => {
  const project_id = 2 // replace this with your actual Project ID.
  const issues = await gitlab.Issues.all(project_id);
  return {
    props: {
      issues
    }
  };
}

You can find your Project ID at the top of your Project Details page. In my project, it looks like this:

Image

Note: My blog is actually in a project called "Content" within a group called "Blog" which might look slightly different for you if your blog is a project called "Blog."

All together, this should be enough to generate a list of issues on your home page:

// pages/index.js

import gitlab from "../utils/gitlab";
import Link from "next/link";
import slugify from "@sindresorhus/slugify";

export default ({ issues }) => {
  return (
    <ul>
      {issues.map(issue => (
        <li key={issue.iid}>
          <Link href="/issue/[title]" as={`/issue/${slugify(issue.title)}`}>
            <a>
              {issue.title}
            </a>
          </Link>
        </li>
      ))}
    </ul>
  );
};

export const getStaticProps = async () => {
  const project_id = 2; // replace this with your actual Project ID.
  const issues = await gitlab.Issues.all(project_id);
  return {
    props: {
      issues
    }
  };
}

Rendering your blog post

We'll start by defining a getStaticPaths function in our /pages/issue/[title].js file. The getStaticPaths function dynamically generates all the paths for our static blog pages at build time.

We want getStaticPaths to fetch generate pages for each of our issues. To do this, use the gitlab.js util to get all the issues of your project. Then we'll map over all the issues, returning an array of URL paths that are /issue/ + slugify(issue.title). That array will be the paths property that we return in an object at the end of our function. It will look like this:

// pages/issue/[title].js

import gitlab from "../../utils/gitlab";
import slugify from "@sindresorhus/slugify";

export const getStaticPaths = async () => {
  const project_id = 2; // replace this with your actual Project ID.
  const issues = await gitlab.Issues.all(project_id);
  return {
    paths: issues.map(issue => `/issue/${slugify(issue.title)}`),
    fallback: false
  };
};

Now that Next.js knows what pages to generate, lets define what a page will look like by exporting a default function.

// pages/issue/[title].js


import gitlab from "../../utils/gitlab";
import slugify from "@sindresorhus/slugify";

export default ({ title, content}) => {
  return (
    <main>
      <article>
        <h1>{title}</h1>
        <section dangerouslySetInnerHTML={content} />
      </article>
    </main>
  );
};

export const getStaticPaths = async () => {
  const project_id = 2; // replace this with your actual Project ID.
  const issues = await gitlab.Issues.all(project_id);
  return {
    paths: issues.map(issue => `/issue/${slugify(issue.title)}`),
    fallback: false
  };
};

That's a pretty simple function so far. It expects a title and content prop. The title goes right into an <h1>, but why are we using React's dangerouslySetInnerHTML to set the issue content? As we're about to see, the content we get from the GitLab API is the markdown we write in the issue! So we need to transform that markdown into HTML, and then use dangerouslySetInnerHTML to insert that content into the <section> tag. Don't worry; we'll use a library to prevent xss here. But first, we need to get the title and content props.

To get these props, we'll define a getStaticProps function. We use getStaticProps to fetch data used to generate our pages at build time.

export const getStaticProps = async ({ params }) => {
  const { title } = params;
  const project_id = 2; // replace this with your actual Project ID.
  const issues = await gitlab.Issues.all(project_id);
  const [issue] = issues.filter(issue => slugify(issue.title) === title);
  const { description } = issue;
  return {
    props: {
      title: issue.title,
      content: description
    }
  };
};

So there is a little bit going on here, let's break it down.

const { title } = params;

Here the title property of the params object is the [title] of the blog post we are on, which is the URL slug of our issue title.

const issues = await gitlab.Issues.all(project_id);
const [issue] = issues.filter(issue => slugify(issue.title) === title);

Here we get all the issues of our project, like we did on the homepage. Then we filter through though issues, creating an array of all the issues with a URL slug title that matches the title of the page we are on. Unless you have two issues with the same title, this array should end with one issue object in it, which we destructure out of the final array.

const { description } = issue;

We then destructure the issue description into it's own variable. This description is the markdown content of your issue.

return {
  props: {
    title: issue.title,
    content: description
  }
};

Finally, we return our props for our default function to consume. The title prop is the issue.title and the content prop is the issue description.

However, we're not done here yet. We haven't turned our description markdown into HTML yet.

Let's make a createMarkup function that takes a markdown string as an argument and returns HTML by using marked. We'll want this function to provide some other nice features such as:

  1. Prevent XSS attacks from coming through our markdown and into our markup by using JSDOM & DOMPurify
  2. Convert emoji shortcode, such as :smile: to equivalent emoji image 😄 by using emoji-toolkit(previously known as emojione.)
  3. Syntax highlighting with highlight.js

That createMarkup function can look like this:

function createMarkup(markdown) {
  const window = new JSDOM("").window;
  const DOMPurify = createDOMPurify(window);
  return {
    __html: DOMPurify.sanitize(
      emoji.shortnameToImage(
        marked(markdown, {
          highlight: function(code, lang) {
            return hl.highlight(lang, code).value;
          }
        })
      )
    )
  };
}

Note: We are returning an Object with an __html property and our sanitized markup as our value. This is required for using dangerouslySetInnerHTML.

We can then get HTML by passing our issue description markdown to createMarkup in our getStaticProps function. Together, it will look something like this:

// pages/issue/[title].js

import gitlab from "../../utils/gitlab";
import slugify from "@sindresorhus/slugify";
import hl from "highlight.js";
import marked from "marked";
import createDOMPurify from "dompurify";
import { JSDOM } from "jsdom";
import emoji from "emoji-toolkit";

// ... default function and getStaticPaths hidden here ...

export const getStaticProps = async ({ params }) => {
  const { title } = params;
  const project_id = 2; // replace this with your actual Project ID.
  const issues = await gitlab.Issues.all(project_id);
  const [issue] = issues.filter(issue => slugify(issue.title) === title);
  const { description } = issue;
  return {
    props: {
      title: issue.title,
      content: createMarkup(description)
    }
  };
};

function createMarkup(markdown) {
  const window = new JSDOM("").window;
  const DOMPurify = createDOMPurify(window);
  return {
    __html: DOMPurify.sanitize(
      emoji.shortnameToImage(
        marked(markdown, {
          highlight: function(code, lang) {
            return hl.highlight(lang, code).value;
          }
        })
      )
    )
  };
}

One additional thing, we need to modify the rendered marked uses to add your GitLab host URL to your image tags, because the Issues API returns a relative URL rather than an absolute URL.

Modifing the renderer is just a few lines of code:

const renderer = new marked.Renderer();
renderer.image = function(href, title, text) {
  // example project_url: https://gitlab.com/jakeburden/burden.blog or https://brdn.dev/blog/content 
  const project_url = "https://<your-gitlab-host>/<your-group|user>/<your-project>" // replace with your values
  const hostHref = host + href;

  return `<img src=${hostHref} alt=${text} />`;
};

The renderer can than be added to marked as an option:

const renderer = new marked.Renderer();
renderer.image = function(href, title, text) {
  // example project_url: https://gitlab.com/jakeburden/burden.blog or https://brdn.dev/blog/content 
  const project_url = "https://<your-gitlab-host>/<your-group|user>/<your-project>" // replace with your values
  const hostHref = host + href;

  return `<img src=${hostHref} alt=${text} />`;
};

function createMarkup(markdown) {
  const window = new JSDOM("").window;
  const DOMPurify = createDOMPurify(window);
  return {
    __html: DOMPurify.sanitize(
      emoji.shortnameToImage(
        marked(markdown, {
          highlight: function(code, lang) {
            return hl.highlight(lang, code).value;
          },
          renderer
        })
      )
    )
  };
}

Comments

GitLab Issues also work wonderfully as a blog backend because it has a full-featured commenting system built in! Readers can comment on your blog by adding a comment in your GitLab issue. We can then use the GitLab API to fetch these comments and render them on your blog post.

To do this, we'll add a few lines of code to our existing getStaticProps function so we can generate comments at build time.

This will get comments for your issue:

const comments = await gitlab.IssueNotes.all(project_id, issue.iid);

Filter out any system comments, such as assignees changing or issue description changing. Then map over the comments to conver the markdown body of the comment into html. Finally, reverse the comments so we can display them in order of oldest first.

comments
  .filter(comment => !comment.system)
  .map(comment => {
    comment.body = createMarkup(comment.body);
    return comment;
  })
  .reverse()

We'll also destructure web_url from the issue, so we can link back to the comment on GitLab and give the reader a link to our issue to write a new comment.

const { description, web_url } = issue;

The getStaticProps function should now look like this:

export const getStaticProps = async ({ params }) => {
  const { title } = params;
  const project_id = 2; // replace this with your actual Project ID.
  const issues = await gitlab.Issues.all(project_id);
  const [issue] = issues.filter(issue => slugify(issue.title) === title);
  const { description, web_url } = issue;
  const comments = await gitlab.IssueNotes.all(project_id, issue.iid);
  return {
    props: {
      title: issue.title,
      web_url,
      content: createMarkup(description),
      comments: comments
        .filter(comment => !comment.system)
        .map(comment => {
          comment.body = createMarkup(comment.body);
          return comment;
        })
        .reverse()
    }
  };
};

Note: If you anticipate having frequent comments, you might want to investigate other options to fetch these comments from the browser, perhaps by using a "serverless" function.

Now we'll need a Comments component. Within the component, we'll use strftime to render the comments created_at time. For example:

function Comments(comments, web_url) {
  if (comments.length) {
    return (
      <section>
        <p>{comments.length} Comments</p>
        <div>
          {comments.map(comment => (
            <div key={comment.id}>
              <img src={comment.author.avatar_url} />
              <div>
                <p>
                  {comment.author.username} -
                  <a href={`${web_url}/#note_id=${comment.id}`}>
                    <date>
                      {strftime(" %B %d, %Y", new Date(comment.created_at))}
                    </date>
                  </a>
                </p>
                <div dangerouslySetInnerHTML={comment.body}/>
              </div>
            </div>
          ))}
        </div>
        <a href={web_url}>write a comment</a>
      </section>
    );
  }
}

Now we can add our Comments component and additional props to our default function.

export default ({ title, content, comments, web_url}) => {
  return (
    <main>
      <article>
        <h1>{title}</h1>
        <section dangerouslySetInnerHTML={content} />
        {Comments(comments, web_url)}
      </article>
    </main>
  );
};

A complete pages/issue/[title].js can now look like this:

import gitlab from "../../utils/gitlab";

import Link from "next/link";
import slugify from "@sindresorhus/slugify";
import hl from "highlight.js";
import marked from "marked";
import createDOMPurify from "dompurify";
import emoji from "emoji-toolkit";
import strftime from "strftime";
import { JSDOM } from "jsdom";

const renderer = new marked.Renderer();
renderer.image = function(href, title, text) {
  // example project_url: https://gitlab.com/jakeburden/burden.blog or https://brdn.dev/blog/content 
  const project_url = "https://<your-gitlab-host>/<your-group|user>/<your-project>" // replace with your values
  const hostHref = host + href;

  return `<img src=${hostHref} alt=${text} />`;
};

export default ({ title, content, comments, web_url}) => {
  return (
    <main>
      <article>
        <h1>{title}</h1>
        <section dangerouslySetInnerHTML={content} />
        {Comments(comments, web_url)}
      </article>
    </main>
  );
};

export const getStaticPaths = async () => {
  const issues = await gitlab.Issues.all(2);
  return {
    paths: issues.map(issue => `/issue/${slugify(issue.title)}`),
    fallback: false
  };
};

export const getStaticProps = async ({ params }) => {
  const { title } = params;
  const project_id = 2; // replace this with your actual Project ID.
  const issues = await gitlab.Issues.all(project_id);
  const [issue] = issues.filter(issue => slugify(issue.title) === title);
  const { description, web_url } = issue;
  const comments = await gitlab.IssueNotes.all(project_id, issue.iid);
  return {
    props: {
      title: issue.title,
      web_url,
      content: createMarkup(description),
      comments: comments
        .filter(comment => !comment.system)
        .map(comment => {
          comment.body = createMarkup(comment.body);
          return comment;
        })
        .reverse()
    }
  };
};

function createMarkup(markdown) {
  const window = new JSDOM("").window;
  const DOMPurify = createDOMPurify(window);
  return {
    __html: DOMPurify.sanitize(
      emoji.shortnameToImage(
        marked(markdown, {
          highlight: function(code, lang) {
            return hl.highlight(lang, code).value;
          },
          renderer
        })
      )
    )
  };
}

function Comments(comments, web_url) {
  if (comments.length) {
    return (
      <section>
        <p>{comments.length} Comments</p>
        <div>
          {comments.map(comment => (
            <div key={comment.id}>
              <img src={comment.author.avatar_url} />
              <div>
                <p>
                  {comment.author.username} -
                  <a href={`${web_url}/#note_id=${comment.id}`}>
                    <date>
                      {strftime(" %B %d, %Y", new Date(comment.created_at))}
                    </date>
                  </a>
                </p>
                <div dangerouslySetInnerHTML={comment.body}/>
              </div>
            </div>
          ))}
        </div>
        <a href={web_url}>write a comment</a>
      </section>
    );
  }
}

Conclusion

You should now have a static blog generated by Next.js with content fetched from GitLab Issues at build time.

TL;DR

pages/index.js

import gitlab from "../utils/gitlab";

import Link from "next/link";
import slugify from "@sindresorhus/slugify";

export default ({ issues }) => {
  return (
    <ul>
      {issues.map(issue => (
        <li key={issue.iid}>
          <Link href="/issue/[title]" as={`/issue/${slugify(issue.title)}`}>
            <a>
              {issue.title}
            </a>
          </Link>
        </li>
      ))}
    </ul>
  );
};

pages/issue/[title].js

import gitlab from "../../utils/gitlab";

import Link from "next/link";
import slugify from "@sindresorhus/slugify";
import hl from "highlight.js";
import marked from "marked";
import createDOMPurify from "dompurify";
import emoji from "emoji-toolkit";
import strftime from "strftime";
import { JSDOM } from "jsdom";

const renderer = new marked.Renderer();
renderer.image = function(href, title, text) {
  // example project_url: https://gitlab.com/jakeburden/burden.blog or https://brdn.dev/blog/content 
  const project_url = "https://<your-gitlab-host>/<your-group|user>/<your-project>" // replace with your values
  const hostHref = host + href;

  return `<img src=${hostHref} alt=${text} />`;
};

export default ({ title, content, comments, web_url}) => {
  return (
    <main>
      <article>
        <h1>{title}</h1>
        <section dangerouslySetInnerHTML={content} />
        {Comments(comments, web_url)}
      </article>
    </main>
  );
};

export const getStaticPaths = async () => {
  const issues = await gitlab.Issues.all(2);
  return {
    paths: issues.map(issue => `/issue/${slugify(issue.title)}`),
    fallback: false
  };
};

export const getStaticProps = async ({ params }) => {
  const { title } = params;
  const project_id = 2; // replace this with your actual Project ID.
  const issues = await gitlab.Issues.all(project_id);
  const [issue] = issues.filter(issue => slugify(issue.title) === title);
  const { description, web_url } = issue;
  const comments = await gitlab.IssueNotes.all(project_id, issue.iid);
  return {
    props: {
      title: issue.title,
      web_url,
      content: createMarkup(description),
      comments: comments
        .filter(comment => !comment.system)
        .map(comment => {
          comment.body = createMarkup(comment.body);
          return comment;
        })
        .reverse()
    }
  };
};

function createMarkup(markdown) {
  const window = new JSDOM("").window;
  const DOMPurify = createDOMPurify(window);
  return {
    __html: DOMPurify.sanitize(
      emoji.shortnameToImage(
        marked(markdown, {
          highlight: function(code, lang) {
            return hl.highlight(lang, code).value;
          },
          renderer
        })
      )
    )
  };
}

function Comments(comments, web_url) {
  if (comments.length) {
    return (
      <section>
        <p>{comments.length} Comments</p>
        <div>
          {comments.map(comment => (
            <div key={comment.id}>
              <img src={comment.author.avatar_url} />
              <div>
                <p>
                  {comment.author.username} -
                  <a href={`${web_url}/#note_id=${comment.id}`}>
                    <date>
                      {strftime(" %B %d, %Y", new Date(comment.created_at))}
                    </date>
                  </a>
                </p>
                <div dangerouslySetInnerHTML={comment.body}/>
              </div>
            </div>
          ))}
        </div>
        <a href={web_url}>write a comment</a>
      </section>
    );
  }
}

5 Comments

jake - March 15, 2020

test 123

jake - March 15, 2020

test 123

💯

Doge - March 15, 2020

Test from user.

write a comment