Posts in Next.js on Cloudflare Pages

I rewrote my site in Next.js, hosting on Cloudflare Pages via next-on-pages.

Next-on-pages works well, but the setup isn't obvious if you're unfamiliar with Cloudflare's deployment model. Here's how I implemented a blog system.

## MDX Support

I chose next-mdx-remote for its clean API and React component rendering.

Create mdx.d.ts in your project root:

1declare module "*.mdx" {
2 let MDXComponent: (props) => JSX.Element;
3 export default MDXComponent;
4}

Configure next.config.js for static export (required for Cloudflare Pages):

1const nextConfig = {
2 /// ....
3 output: "export",
4};

## Fetching Posts

I created fetch-posts.ts with two functions: fetch all posts, fetch by slug.

Read the posts directory with Node's fs module, filter for .mdx files, skip unpublished posts:

1import matter from "gray-matter";
2import fs from "fs/promises";
3import path from "path";
4import type { Post } from "../types";
5
6export const getPosts = async () => {
7 const posts = await fs.readdir("./src/posts/");
8
9 return Promise.all(
10 posts
11 .filter((file) => path.extname(file) === ".mdx")
12 .map(async (file) => {
13 const filePath = `./src/posts/${file}`;
14
15 const postContent = await fs.readFile(filePath, "utf8");
16
17 const { data, content } = matter(postContent);
18
19 if (data.published === false) {
20 return null;
21 }
22
23 return {
24 title: data.title,
25 slug: data.slug,
26 date: data.date,
27 description: data.description,
28 views: data.views || null,
29 body: content,
30 } as Post;
31 }),
32 );
33};

Single post fetch leverages getPosts:

1export async function getPost(slug: string) {
2 const posts = await getPosts();
3 return posts.find((post) => post?.slug === slug);
4}

## Rendering

Custom components for markdown elements live in markdown-component.tsx:

1function Strong(
2 props: React.DetailedHTMLProps<
3 React.HTMLAttributes<HTMLElement>,
4 HTMLElement
5 >,
6) {
7 const { children, ...rest } = props;
8 return (
9 <b {...rest} className="text-foreground font-semibold">
10 {children}
11 </b>
12 );
13}
14
15
16export const mdxComponents: MDXComponents = {
17 strong: Strong,
18};

The post-body.tsx component renders markdown with remark and rehype plugins:

1import { MDXRemote } from "next-mdx-remote/rsc";
2
3import remarkGfm from "remark-gfm";
4import rehypeSlug from "rehype-slug";
5import rehypeAutolinkHeadings from "rehype-autolink-headings";
6import remarkA11yEmoji from "@fec/remark-a11y-emoji";
7import remarkToc from "remark-toc";
8import { mdxComponents } from "./markdown-component";
9
10export function PostBody({ children }: { children: string }) {
11 return (
12 <MDXRemote
13 source={children}
14 options={{
15 mdxOptions: {
16 remarkPlugins: [remarkGfm, remarkA11yEmoji, remarkToc],
17 rehypePlugins: [rehypeSlug, rehypeAutolinkHeadings],
18 },
19 }}
20 components={mdxComponents}
21 />
22 );
23}

Pass the post body to PostBody to render. For posts/[id], pre-generate routes with generateStaticParams (edge rendering isn't supported):

1export async function generateStaticParams() {
2 const posts = await getPosts();
3
4 return posts.map((post) => ({
5 id: post!.slug,
6 }));
7}

Generate metadata:

1export async function generateMetadata({ params }: Props): Promise<Metadata> {
2 const { id } = params;
3 const post = await getPost(id);
4
5 if (!post) return notFound();
6
7 return {
8 title: `${post.title} • dromzeh.dev`,
9 description: post.description,
10 metadataBase: new URL("https://dromzeh.dev"),
11 };
12}

Fetch and render in the PostPage component:

1export default async function PostPage({ params: { id } }: Props) {
2 const post = await getPost(id);
3
4 if (!post) return notFound();
5
6 return (
7 <div className="min-h-screen max-w-xl mx-auto flex items-center justify-center">
8 <section className="flex flex-col space-y-4 mt-8 max-w-xl">
9 <PostBody>{post.body}</PostBody>
10 </section>
11 </div>
12 </div>
13 );
14}

## Extending

This foundation supports tags, categories, search, or any other features you need.

Full source on GitHub.