Use Netlify CMS to manage MDXs for NextJs site on Vercel

White, 27 Jul 2021

In this part, I will introduce Netlify CMS to our NextJs site which is hosted on Vercel. It will help managing MDX contents in our Github repository.

To be clear, Netlify CMS is different from Netlify. Netlify is a platform providing services, for example, building and deploying sites but Netlify CMS is a script to manage content.

Why Netlify CMS?

Netlify CMS is a free open source and git-based react application. It works in client side to manage MDX contents inside the repository. Once there are changes in the repository, Vercel will know and re-build the site for us. Becuase of these reasons, it is a perfect couple for NextJs site.

Create Github App

Netlify CMS requires authorization. In our case, it will be done via Github App.

  1. Go to Github.com
  2. Click on "Account settings"
  3. Click on "Developer settings"
  4. Click on "New Github App"
  5. Enter Github App name
  6. Enter Homepage URL i.g. your-domain-name
  7. Enter Callback URL to be your-domain-name/api/callback
  8. Disable webhook
  9. Click on "Save changes"
  10. Your app will be created. Click on "Generate a new client secret" and Copy Client ID and Client Secret
  11. Click on "Permissions & events" on left navigation
  12. Under "Content", Set "Read & Write"
  13. Click on "Save changes"
  14. Click on "Install App" on left navigation
  15. Install app at your repository

Copy Cloudinary Cloud Name and API Key

With limited storage of Github, I prefer storing images in Cloudinary. Additionally, Cloudinary provides image optimization which is a plus.

  1. Go to your cloudinary dashboard
  2. Copy your Cloud Name and API Key

Set Environment Variables on Vercel

  1. Go to your domain dashboard on Vercel
  2. Click on "Settings" on top navigation
  3. Click on "Environment Variables" on left navigation
  4. Enter names and values as followed:\ GITHUB_REPO as NAME and your repository i.g. your-name/your-repository-name\ GITHUB_BASE_URL as NAME and your site's base URL i.g. https://your-domain-name\ OAUTH_GITHUB_CLIENT_ID as NAME and your Client ID as VALUE\ OAUTH_GITHUB_CLIENT_SECRET as NAME and your Client Secret as VALUE\ CLOUDINARY_CLOUD_NAME as NAME and your Cloud Name as VALUE\ CLOUDINARY_API_KEY as NAME and your API Key as VALUE

Introduce Netlify CMS

  1. Create folders and files as followed
+ - config
+   - oauth.js
+   - netlifycms.js
+ - contents
+   - posts
+   - pages
  - pages
+   - admin.js
    - api
+     - auth.ts
+     - callback.ts
+ .env.local
  1. Install typescript, @types/react and @types/simple-oauth2

These packages are required at Build Time.

npm install typescript @types/react @types/simple-oauth2 simple-oauth2 --save-dev
  1. Install simple-oauth2, netlify-cms-app and netlify-cms-media-library-cloudinary
npm install simple-oauth2 netlify-cms-app netlify-cms-media-library-cloudinary
  1. Paste the code below inside the /pages/admin.js file

This will create the main page for Netlify CMS. When I wrote my code, I faced a problem of "window is not define.". We have to give Manuel Kruisz a credit for the solution.

import { useEffect } from 'react';
import { netlifyCMSConfig } from '../config/netlifycms';

export default function AdminPage({ config }) {
    useEffect(() => {
        ;(async () => {
            const CMS = (await import('netlify-cms-app')).default
            const cloudinary = (await import('netlify-cms-media-library-cloudinary')).default
            CMS.registerMediaLibrary(cloudinary);
            CMS.init({ config });
        })()
    }, []);

  return <div />;
};

export async function getStaticProps() {
    const config = netlifyCMSConfig(process.env.GITHUB_REPO,process.env.GITHUB_BASE_URL,process.env.CLOUDINARY_CLOUD_NAME,process.env.CLOUDINARY_API_KEY);
    return {
        props: {
            config
        }
    };
};
  1. Paste code below inside the /config/netlifycms.js file

This file will serve as settings for Netlify CMS. It is customizable, to learn more about the settings, visit this documentation.

export function netlifyCMSConfig(repo, base_url, cloudName, apiKey) {
    return {
        backend: {
            name: 'github',
            repo,
            branch: 'main',
            base_url,
            auth_endpoint: 'api/auth'
        },
        media_library: {
            name: 'cloudinary',
            config: {
                cloud_name: cloudName,
                api_key: apiKey
            } 
        },
        collections: [
            {
                label: 'Posts',
                name: 'posts',
                folder: 'contents/posts',
                extension: 'mdx',
                format: 'frontmatter',
                create: true,
                editor: {preview: false},
                sortable_fields: ['timestamp'],
                view_filters: [
                    {label: 'Drafts', field: 'draft', pattern: true},
                    {label: '#Coding', field: 'category', pattern: 'Coding'},
                    {label: 'Robots', field: 'robots', pattern: false},
                ],
                view_groups: [
                    {label: 'Month-Year:', field: 'publishedDate', pattern: '\\w{3}.\\d{4}'}
                ],
                slug: '{{fields.slug}}',
                fields: [
                    {label: 'Draft', name: 'draft', widget: 'boolean', default: false, required: false},
                    {label: 'Published Date', name: 'publishedDate', widget: 'date', format: 'D MMM YYYY', required: true},
                    {label: 'Title', name: 'title', widget: 'string', required: true},
                    {label: 'Slug', name: 'slug', widget: 'string', required: true},
                    {label: 'Category', name: 'category', widget: 'relation', collection: 'settings', file: 'categories', search_fields: ['categories.*.name'], display_fields: ['categories.*.name'], value_field: 'categories.*.name', multiple: true, required: true},
                    {label: 'Tags', name: 'tags', widget: 'string', default: '', required: false},
                    {label: 'Description', name: 'description', widget: 'string', default: '', required: false},
                    {label: 'Body', name: 'body', widget: 'markdown', default: '', required: true},
                    {label: 'Timestamp', name: 'timestamp', widget: 'datetime', format: 'X', required: true},
                    {label: 'Robots', name: 'robots', widget: 'boolean', default: true, required: false}
                ]
            },
            {
                label: 'Pages',
                name: 'pages',
                folder: 'contents/pages',
                extension: 'mdx',
                format: 'frontmatter',
                create: true,
                editor: {preview: false},
                sortable_fields: ['timestamp'],
                view_filters: [
                    {label: 'Drafts', field: 'draft', pattern: true},
                    {label: 'Nav', field: 'nav', pattern: true},
                    {label: 'Robots', field: 'robots', pattern: false}
                ],
                view_groups: [
                    {label: 'Month-Year:', field: 'publishedDate', pattern: '\\w{3}.\\d{4}'}
                ],
                slug: '{{fields.slug}}',
                fields: [
                    {label: 'Draft', name: 'draft', widget: 'boolean', default: false, required: false},
                    {label: 'Published Date', name: 'publishedDate', widget: 'date', format: 'D MMM YYYY',required: true},
                    {label: 'Title', name: 'title', widget: 'string', required: true},
                    {label: 'Slug', name: 'slug', widget: 'string', required: true},
                    {label: 'Description', name: 'description', widget: 'string', default: '', required: false},
                    {label: 'Body', name: 'body', widget: 'markdown', default: '', required: false},
                    {label: 'Nav', name: 'nav', widget: 'boolean', default: false, required: false},
                    {label: 'Timestamp', name: 'timestamp', widget: 'datetime', format: 'X', required: true},
                    {label: 'Robots', name: 'robots', widget: 'boolean', default: true, required: false}
                ]
            },
            {
                label: 'Settings',
                name: 'settings',
                files: [
                    {
                        label: 'Categories',
                        name: 'categories',
                        create: true,
                        editor: {preview: false},
                        sortable_fields: ['name'],
                        file: 'contents/categories.json',
                        fields: [
                            {
                                label: 'Categories',
                                name: 'categories',
                                widget: 'list',
                                fields: [
                                    {label: 'Name', name: 'name', widget: 'string'}
                                ]
                            }
                        ]
                    }
                ]
            }
        ]
    };
};
  1. Paste code below inside /config/oauth.js file

We will use Github and Oauth for site's authorization.

export default {
  client: {
    id: process.env.OAUTH_GITHUB_CLIENT_ID,
    secret: process.env.OAUTH_GITHUB_CLIENT_SECRET
  },
  auth: {
    tokenHost: 'https://github.com',
    tokenPath: '/login/oauth/access_token',
    authorizePath: '/login/oauth/authorize'
  }
};
  1. Paste code below inside /pages/api/auth.ts file

We will use NextJs Severless Function provided by Vercel to handling authorization via API endpoints. Vercel will convert javascripts (.js) or typescript (.ts) files under /api folder to be serverless functions (see documentation).

Credits:

import { IncomingMessage, ServerResponse } from 'http';
import { AuthorizationCode } from 'simple-oauth2';
import { randomBytes } from 'crypto';
import { config } from './lib/config';

export const randomString = () => randomBytes(4).toString(`hex`);

export default async (req: IncomingMessage, res: ServerResponse) => {
  const { host } = req.headers;
  const client = new AuthorizationCode(config);
  const authorizationUri = client.authorizeURL({
    redirect_uri: `https://${host}/api/callback`,
    scope: 'repo,user',
    state: randomString()
  });

  res.writeHead(301, { Location: authorizationUri });
  res.end();
};
  1. Paste code below inside callback.ts file
import { IncomingMessage, ServerResponse } from 'http';
import { AuthorizationCode } from 'simple-oauth2';
import config from '../../config/oauth';

export default async (req: IncomingMessage, res: ServerResponse) => {
  const { host } = req.headers;
  const url = new URL(`https://${host}/${req.url}`);
  const urlParams = url.searchParams;
  const code = urlParams.get('code');
  const client = new AuthorizationCode(config);
  const tokenParams = {
    code,
    redirect_uri: `https://${host}/api/callback`
  };

  try {
    const accessToken = await client.getToken(tokenParams);
    const token = accessToken.token['access_token'];
    const responseBody = renderBody('success', {
      token
    });

    res.statusCode = 200;
    res.end(responseBody);
  } catch (e) {
    res.statusCode = 200;
    res.end(renderBody('error', e));
  }
};

function renderBody(
  status: string,
  content: {
    token: string;
  }
) {
  return `
    <script>
      const receiveMessage = (message) => {
        window.opener.postMessage(
          'authorization:github:${status}:${JSON.stringify(
    content
  )}',
          message.origin
        );
        window.removeEventListener("message", receiveMessage, false);
      }
      window.addEventListener("message", receiveMessage, false);
      window.opener.postMessage("authorizing:github", "*");
    </script>
  `;
}
  1. Insert Environment Variables into .env.local file (Replace them with yours)
GITHUB_REPO=your-github-repo
GITHUB_BASE_URL=your-github-base-url
OAUTH_GITHUB_CLIENT_ID=your-github-client-id
OAUTH_GITHUB_CLIENT_SECRET=your-github-client-secret
CLOUDINARY_CLOUD_NAME=your-cloud-name
CLOUDINARY_API_KEY=your-api-key
  1. Update .eslintrc file
{
  "extends": ["next", "next/core-web-vitals"],
  "rules": {
    "react/display-name": "off",
    "react/no-unescaped-entities": "off"
  }
}
  1. Update next.config.js file
module.exports = {
  reactStrictMode: true,
  images: {
    domains: [
      'res.cloudinary.com'
    ]
  }
}
  1. Push your codes to the repository by running these commands
git add .
git commit -m "netlifycms"
git push origin main

Test Netlify CMS

  1. Go to your-domain-name/admin
  2. Click on "Sign in with Github"

If authorization is success, you will see Netlify CMS dashboard. Test all functionalities.

The related MDX files will be stored inside /contents/posts and /content/pages. It will not reflect in your code editor. To update codes inside your code editor, run this command.

git pull origin main

That is all. Congratulation!!

In the next part, I will code to display content created by Netlify CMS.