Super simple file based "CMS" for NextJs projects
Joe Kent
Posted on April 24, 2020
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?
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);
}
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 }), {});
}
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;
...
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;
}
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}>
...
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;
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
function mapPostList(posts, content) {
return posts.split(',').map((key) => ({
title: content[`post${key}Title`],
description: content[`post${key}Description`],
}));
}
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');
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', '')}`);
}
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,
};
}
I hope the internet finds this helpful and let me know if you end up using this for your own project!
Posted on April 24, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 30, 2024