Using GitHub as a CMS

Why recreate content when it's already available through GitHub?

When I was building this website, I wondered: where should I store the content? Since I'm using Nuxt, I first looked into Nuxt Content (which powers this site). It works great, and I can manage everything through the web interface provided by Nuxt Studio.

Pages? Covered ✅. Portfolio? Check ✅. But all of my projects are already available on GitHub. So why not fetch them directly from there? It's also a good opportunity to test the GitHub API!

Testing the GitHub API

At first, I looked into fetching data using the GitHub REST API. It's fairly straightforward: you'll need to create a token here. Since I only want to fetch public repositories, no additional permissions are needed.

In Nuxt, sensitive data like tokens should be stored in a .env file at the root of your project (don't forget to add .env to your .gitignore to prevent leaking sensitive data).

.env
NUXT_GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxx

Now, in your nuxt.config.ts, declare the variable in the runtimeConfig object. I set it as an empty string so Nuxt can generate type definitions automatically.

nuxt.config.ts
export default defineNuxtConfig({
  runtimeConfig: {
    githubToken: '',
  },
})

To experiment with the REST API, I created a simple server-side API route using Nuxt's server engine: Nitro. Just add a file named server/api/github-repos.get.ts. This creates a GET route at /api/github-repos.

github-repos.get.ts
// Get the Github REST API typings, npm install -D @octokit/openapi-types
import { operations } from '@octokit/openapi-types'

// Moving types to be more readable
type GithubReposRes = operations['repos/list-for-user']['responses']['200']['content']['application/json']
type GithubReposQuery = operations['repos/list-for-user']['parameters']['query']

export default defineEventHandler(async (event) => {
  // Get our githubToken referenced inside our nuxt.config.ts
  const config = useRuntimeConfig()
  const githubToken = config.githubToken

  // Throw an error via h3 (available thanks to Nitro)
  if (!githubToken) {
    throw createError({
      statusCode: 401,
      statusMessage: 'Unauthorized',
      message: 'GitHub token is not provided'
    })
  }

  try {
    // Fetch with the unjs/ofetch shipped with Nuxt
    // Get the list of my repository. Replace <pseudo> with your GitHub username
    const repos = await $fetch<GithubReposRes>(
      '/users/<pseudo>/repos',
      {
        headers: {
          'Authorization': `Bearer ${githubToken}`,
          'Accept': 'application/vnd.github.v3+json',
          'X-GitHub-Api-Version': '2022-11-28'
        },
        baseURL: 'https://api.github.com',
        query: {
          type: 'all', // Get all repositores (mine and other I am member for)
          per_page: 100, // Limited by 100 items per page by the API but I have only around fifty repositories
        } as GithubReposQuery
      }
    )
  }
  catch {
    throw createError({
      statusCode: 500,
      statusMessage: 'Error',
      message: 'An error occurred while calling the GitHub API'
    })
  }

  // Filter to only get non fork repositories and return only useful data.
  return repos
    .filter(repo => !repo.fork)
    .map(repo => ({
      name: repo.name,
      stars: repo.stargazers_count,
      url: repo.html_url,
      description: repo.description
    }))
})

This API returns a list of repositories with just the data I need to display my GitHub projects:

[
  {
    "name": "blottie",
    "stars": 13,
    "url": "https://github.com/Applelo/blottie",
    "description": "Lottie component for VueJS 3 / Nuxt 3"
  },
  {
    "name": "compotes",
    "stars": 5,
    "url": "https://github.com/Applelo/compotes",
    "description": "🍯 Components library focused on accessibility/customization"
  }
]

However, I only want to display the pinned repositories from my GitHub profile. Unfortunately, this isn't available with the REST API but GitHub offers something better: the GraphQL API.

Get the pinned repositories via the GraphQL API

If you're unfamiliar with GraphQL, it's a query language for APIs that allows you to request exactly the data you need. On GitHub, for example, I can fetch only the name, stars, URL, and description—nothing more. This makes the request very efficient.

GitHub's GraphQL API uses the same base URL and token-based auth as REST. The only changes are:

  • Use POST instead of GET
  • Change the Accept header
  • Use /graphql as the endpoint
const repos = await $fetch('/graphql', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${githubToken}`,
    Accept: 'application/json',
  },
  baseURL: 'https://api.github.com',
  body: {
    query: `/* GraphQL query defined below */`,
  },
})

To make your own query, you can check the GithHub GraphQL explorer and play with it. Here's the GraphQL query I used to fetch my pinned repositories:

query {
  user(login: "applelo") {
    pinnedItems(first: 6) {
      nodes {
        ... on Repository {
          name
          description
          stargazerCount
          url
          languages(first: 1, orderBy: {field:SIZE, direction: DESC}) {
            nodes {
              name
            }
          }
        }
      }
    }
  }
}

I could have used nuxt-graphql-middleware or grapqhl-codegen, but that would be overkill for a single request. Instead, I fetched the response, converted the JSON to TypeScript types using transform.tools. Assembled together I get something like this.

github-pinned.get.ts
interface GithubPinnedItem {
  name: string
  description: string
  stargazerCount: number
  url: string
  languages: {
    nodes: Array<{
      name: string
    }>
  }
}

interface GithubPinnedItems {
  data: {
    user: {
      pinnedItems: {
        nodes: GithubPinnedItem[]
      }
    }
  }
}

export default defineEventHandler(async (event) => {
  const config = useRuntimeConfig()
  const githubToken = config.githubToken

  if (!githubToken) {
    throw createError({
      statusCode: 401,
      statusMessage: 'Unauthorized',
      message: 'GitHub token is not provided',
    })
  }

  try {
    const repos = await $fetch<GithubPinnedItems>('/graphql', {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${githubToken}`,
        Accept: 'application/json',
      },
      baseURL: 'https://api.github.com',
      body: {
        query: `
        query {
          user(login: "applelo") {
            pinnedItems(first: 6) {
              nodes {
                ... on Repository {
                  name
                  description
                  stargazerCount
                  url
                  languages(first: 1, orderBy: {field:SIZE, direction: DESC}) {
                    nodes {
                      name
                    }
                  }
                }
              }
            }
          }
        }
        `,
      },
    })

    // Clean the data returned by the API route
    return repos.data.user.pinnedItems.nodes.map(item => ({
      ...item,
      languages: item.languages.nodes.map(lang => lang.name),
    }))
  }
  catch {
    throw createError({
      statusCode: 500,
      statusMessage: 'Error',
      message: 'An error happen by calling GitHub API',
    })
  }
})

Here’s the returned data:

[
  {
    "name": "vite-plugin-browser-sync",
    "description": "Add BrowserSync in your Vite project ",
    "stargazerCount": 80,
    "url": "https://github.com/Applelo/vite-plugin-browser-sync",
    "languages": [
      "TypeScript"
    ]
  },
  {
    "name": "unplugin-inject-preload",
    "description": "A plugin for injecting <link rel='preload'> for ViteJS, HTMLWebpackPlugin and HTMLRspackPlugin",
    "stargazerCount": 35,
    "url": "https://github.com/Applelo/unplugin-inject-preload",
    "languages": [
      "HTML"
    ]
  },
  {
    "name": "blottie",
    "description": "Lottie component for VueJS 3 / Nuxt 3",
    "stargazerCount": 13,
    "url": "https://github.com/Applelo/blottie",
    "languages": [
      "TypeScript"
    ]
  },
  {
    "name": "compotes",
    "description": "🍯 Components library focused on accessibility/customization",
    "stargazerCount": 5,
    "url": "https://github.com/Applelo/compotes",
    "languages": [
      "TypeScript"
    ]
  },
  {
    "name": "vite-plugin-svg-spritemap",
    "description": "Vite plugin to generate svg spritemap",
    "stargazerCount": 81,
    "url": "https://github.com/SpiriitLabs/vite-plugin-svg-spritemap",
    "languages": [
      "TypeScript"
    ]
  },
  {
    "name": "get-graphql-schema",
    "description": "Get your GraphQL schema easily by providing the API URL",
    "stargazerCount": 0,
    "url": "https://github.com/Applelo/get-graphql-schema",
    "languages": [
      "Vue"
    ]
  }
]

Cache the data thanks to Nitro

To improve performance, we can cache this API response on the server side. This is really easy thanks to Nitro: we can cache the response by replacing defineEventHandler with defineCachedEventHandler.

It works just like the regular handler but allows you to define a caching strategy. Since my pinned repositories don’t change frequently, I cache the response for one day thanks to the maxAge property.

export default defineCachedEventHandler(async (event) => {
/* Your code here */
}, { maxAge: 60 * 60 * 24 /* 1 day */ })

Use the data on front side

Our server-side setup is complete. Now we need to fetch the data through Nuxt's frontend. Since we have made an API route on nuxt server side, the client side will find and type directly the call on the front side if we are using Nuxt fetch functions like useFetch or $fetch with useAsyncData.

For this blog post, I’ll use useFetch because it handles reactivity and SSR gracefully.

<script setup lang="ts">
const { data: pinnedItems } = useFetch('/api/github-pinned')
</script>

<template>
  <ul v-if="pinnedItems && pinnedItems.length">
    <li v-for="item in pinnedItems" :key="item.name">
      {{ item.name }} with {{ item.stargazerCount }} stars
    </li>
  </ul>
</template>

Conclusion

I didn't cover everything Nuxt and Nitro have to offer like manual cache invalidation or advanced middleware features because that's outside the scope of this post (maybe a topic for the next one 😃). To go further, we could imagine generating project pages from each repository’s README.md file using Nuxt Content’s Markdown capabilities.

What I loved about programming is thinking about a solution, try it and sometimes discovering something even better along the way.