DEV Community

Joe Kent
Joe Kent

Posted on

Super simple file based "CMS" for NextJs projects

Tell me if you've been here before.

You're coding up a new personal blog or a tiny website for a friend using a React framework like NextJs, because it's a quick way to render an entire static website built with React. But you don't want to deal with setting up a CMS or writing an integration for Contentful and setting up proper content models. You just need some key/values and maybe Markdown support.

File system to the rescue!

Below is a quick guide to loading arbitrary key/value pairs from .txt files at build time, and injecting them into your React components.

Much of this will reference specific NextJS features, but the principles are applicable to any React application (checkout babel-plugin-preval for example).

First, create a content directory in the root of your project and make some .txt files that look like this,

@hello
World

@key
Value value value!

*Tap mic*, is this still working?
Enter fullscreen mode Exit fullscreen mode

Then create a src/content.js file to store all of your content helper functions.

To start, you'll need a function that can read the file and parse it correctly. There is certainly a lot one could expand upon here (comments, inline variables, ...), but this was enough for me.

export function parseContent(text) {
  const content = {};

  const lines = text.split('\n');

  let target = null;

  lines.forEach((line) => {
    if (line.startsWith('@')) {
      target = line.replace('@', '').trim();
      content[target] = '';
    } else if (!!target && typeof content[target] === 'string') {
      if (!!line.trim().length) {
        if (!!content[target].length) {
          content[target] += '\n';
        }

        content[target] += line;
      }
    }
  });

  return content;
}

export async function loadContentFile(fs, path, file) {
  const fsPromises = fs.promises;

  const filePath = path.join(process.cwd(), 'content', `${file}.txt`);
  const fileContents = await fsPromises.readFile(filePath, 'utf8');

  return parseContent(fileContents);
}
Enter fullscreen mode Exit fullscreen mode

Depending on how you architect your site content, you might need multiple files per page for global components like a navigation bar or footer. In which case, it would be helpful to also have a wrapper function for loading many files,

export async function loadManyContentFiles(fs, path, files) {
  const result = await Promise.all(files.map((file) => loadContentFile(fs, path, file)));

  return result.reduce((acc, data) => ({ ...acc, ...data }), {});
}
Enter fullscreen mode Exit fullscreen mode

This next part will vary depending on how your application is structured, but the general principle is that you'll want to call these content loading functions from the root component of your page that is being server side rendered.

Here is an example with NextJS,

import { loadManyContentFiles } from '../content';

export async function getStaticProps(context) {
  const content = await loadManyContentFiles(fs, path, [
    'pages/demo',
    'components/footer',
  ]);

  return {
    props: {
      content,
    },
  }
}

export default function Homepage(props) {
  const { content } = props;

  const {
    hello,
    key,
  } = content;

  ...
Enter fullscreen mode Exit fullscreen mode

But now we have a new problem, how do we get content to the child components?

Simple, use React context!

In our content helper file, setup a reusable context provider and custom hook function that child components can use.

import { createContext, useContext } from 'react';

export const ContentContext = createContext({});

export function useContent() {
  const content = useContext(ContentContext);

  return content;
}
Enter fullscreen mode Exit fullscreen mode

Then in your page you need to setup the provider,

import { ContentContext, loadManyContentFiles } from '../content';

export async function getStaticProps(context) {
  ...
}

export default function Homepage(props) {
  const { content } = props;

  const {
    hello,
    key,
  } = content;

  return (
    <ContentContext.Provider value={content}>
    ...
Enter fullscreen mode Exit fullscreen mode

Now within any child component, you can reference the useContent hook and pull in key/value pairs.

import { useContent } from '../content';

export default function Footer() {
  const content = useContent();

  const {
    footerLists,
  } = content;
Enter fullscreen mode Exit fullscreen mode

And now you're fully setup with a file system based "CMS"!

Here are a few additional tricks you can use to get more out of this,

Dynamic lists

Want to make a list? Or better yet, want to tie multiple key/value pairs together in a list? No problem!

@post1Title
...

@post1Description
...

@post2Title
...

@post2Description
...

@post3Title
...

@post3Description
...

@posts
1,2,3
Enter fullscreen mode Exit fullscreen mode
function mapPostList(posts, content) {
  return posts.split(',').map((key) => ({
    title: content[`post${key}Title`],
    description: content[`post${key}Description`],
  }));
}
Enter fullscreen mode Exit fullscreen mode

Markdown

Plaintext is cool, but have you ever tried Markdown?

There are a lot of great Markdown parsers for Javascript (eg: marked, markdown-it) and React (eg: marksy). And with this "CMS", you can use any of them! Just write Markdown as the value, and pass it into a parser.

My one flag is that because of how the functions load in content, I had to write this small helper function to make sure line breaks were correctly applied when using Marksy.

  const finalContent = (pageContent || '')
    .split('\n').map((line) => `${line}\n\n`).join('\n');
Enter fullscreen mode Exit fullscreen mode

Load a directory to create a list of paths

When generating a static site, you need a list of paths to build for. To do that, just create another helper function in your content.js file,

export function loadAllPagePaths(fs, path) {
  const postsDirectory = path.join(process.cwd(), 'content/pages');
  const filenames = fs.readdirSync(postsDirectory);

  return filenames.map((name) => `${name.replace('.txt', '')}`);
}
Enter fullscreen mode Exit fullscreen mode

If you're using NextJs, you can then toss this into the static paths function,

export async function getStaticPaths() {
  const pages = loadAllPagePaths(fs, path);

  return {
    paths: pages.map((page) => ({ params: { slug: [page] } })),
    fallback: false,
  };
}
Enter fullscreen mode Exit fullscreen mode

I hope the internet finds this helpful and let me know if you end up using this for your own project!

Top comments (0)