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.