I recently updated (again) the implementation of my own blog (the one you are reading right now). It's now all based on Effect, NextJs, and MDX.
In this article I will guide you through the implementation of my blog:
- Use Effect to extract, validate, and provide all local articles
- Use NextJs to generate all articles pages for performance and SEO
- Write all the content using MDX in a local folder
Previously I was using
contentlayer, you can read all the details here: How to create a Blog with Contentlayer and NextJs
Dependencies: Effect, Next, MDX
My blog is all based on 3 major pillars:
- Effect: Implements all the logic to extract, validate, and provide local content (articles) and all related services (e.g. newsletter)
- NextJs: The blog runs the latest version of
nextjs, using theappdirectory, server components, andgenerateStaticParamsto generate article pages at build time - MDX: All the articles are written using mdx, and then rendered in html using
next-mdx-remote
"dependencies": {
"@effect/platform": "^0.42.6",
"@effect/schema": "^0.60.6",
"effect": "2.1.2",
"next": "^14.1.0",
"next-mdx-remote": "^4.4.1",
// ...
}All the other dependencies add extra features to support these 3 packages:
shikiji: Syntax highlightingdate-fns: Dates formattingtailwindcssrehypeplugins
Local MDX content
All articles are stored in a local data/articles folder.
Each article is a .mdx file:
- The name of the file becomes the article's
slug - Each file contains a
frontmatterwith all the article's metadata (e.g. title, description, category)
All I need to do to write a new article is... just write 💁🏼♂️
Effect: Content service
I use Effect and Node to extract and validate all the articles from the local data/articles folder. Nothing more, nothing less.
A single ContentService does all the work:
export interface ContentService {
readonly _: unique symbol;
}
const make = {
getAllArticles: // ...
getArticleBySlug: (slug: string) => // ...
};
type ContentServiceImpl = typeof make;
export const ContentService = Context.Tag<ContentService, ContentServiceImpl>(
"@app/ContentService"
);
export const ContentServiceLive = Layer.succeed(
ContentService,
ContentService.of(make)
);Inside make I define all the API. I then create and export the service using Context.Tag and Layer.succeed.
If you are interested in learning more about Effect you can read: Complete introduction to using Effect in Typescript
Node: path and fs
In the past I was using many different external dependencies to fetch, validate, reload the local content.
Recently I simplified everything to just using node:
pathandfs🤝
An Effect is responsible to get all the files from the local data/articles directory, extract all metadata, and return all published articles:
import * as Fs from "fs";
import * as NodePath from "path";
const getAllFromDir = Effect.gen(function* (_) {
const dir = NodePath.join(process.cwd(), "data", "articles");
const mdxFiles = Fs.readdirSync(dir).filter(
(file) => NodePath.extname(file) === ".mdx"
);
const content = yield* _(
Effect.all(
mdxFiles.map((file) => extractMetadata({ dir, file })),
{ concurrency: "unbounded" }
)
);
return pipe(
content,
filterPublished,
sortByUpdatedDate
);
});Metadata, slug, and frontmatter
extractMetadata also uses Effect to read each file and extract all the information about the article (slug, raw content, table of contents):
const extractMetadata = ({ dir, file }: { dir: string; file: string }) =>
Effect.gen(function* (_) {
const filePath = NodePath.join(dir, file);
const content = Fs.readFileSync(filePath, "utf-8");
// 👇 Slug (from file name)
const slug = NodePath.basename(file, NodePath.extname(file));
// 👇 Metadata
const tableOfContents = getTableOfContents(content);
const readingTime = computeReadingTime(content);
// 👇 Frontmatter
const { rawFrontmatter, rawSource: source } = parseFrontmatter(content);
const frontmatter = yield* _(
rawFrontmatter,
Schema.parseEither(Frontmatter),
Effect.mapError(
(error) => new ContentError({ reason: "frontmatter", error })
)
);
return { slug, source, frontmatter, tableOfContents, readingTime };
});There is more 🤩
Every week I dive headfirst into a topic, uncovering every hidden nook and shadow, to deliver you the most interesting insights
Not convinced? Well, let me tell you more about it
NextJs article pages
The last piece of the puzzle is serving the content using nextjs.
For the page with the list of articles I simply call the getAllArticles function from ContentService.
I do this directly using a main function inside page.tsx:
const main = Effect.gen(function* (_) {
const contentService = yield* _(Content.ContentService);
return yield* _(contentService.getAllArticles);
}).pipe(
Effect.provide(Content.ContentServiceLive)
);
export default async function Page() {
const articles = await main.pipe(Effect.runPromise);
return ( /** */ );
}Build articles: generateStaticParams
All the articles are generated at build time.
const main = (slug: string) =>
Effect.gen(function* (_) {
const contentService = yield* _(Content.ContentService);
return yield* _(
contentService.getArticleBySlug({ slug })
);
}).pipe(Effect.provide(Content.ContentServiceLive));
const mainAll = Effect.gen(function* (_) {
const contentService = yield* _(Content.ContentService);
return yield* _(
contentService.getAllArticles,
Effect.map(ReadonlyArray.map((s) => ({ slug: s.slug })))
);
}).pipe(Effect.provide(Content.ContentServiceLive));
export async function generateStaticParams() {
return await mainAll.pipe(Effect.runPromise);
}
export default async function Page({
params: { slug },
}: {
params: { slug: string };
}) {
const { source, tableOfContents, frontmatter, readingTime } = await main(
slug
).pipe(
Effect.catchAll(() => Effect.sync(() => redirect("/articles"))),
Effect.runPromise
);
return ( /** */ );
}I use generateStaticParams to provide all the slugs using getAllArticles:
const main = (slug: string) =>
Effect.gen(function* (_) {
const contentService = yield* _(Content.ContentService);
return yield* _(
contentService.getArticleBySlug({ slug })
);
}).pipe(Effect.provide(Content.ContentServiceLive));
const mainAll = Effect.gen(function* (_) {
const contentService = yield* _(Content.ContentService);
return yield* _(
contentService.getAllArticles,
Effect.map(ReadonlyArray.map((s) => ({ slug: s.slug })))
);
}).pipe(Effect.provide(Content.ContentServiceLive));
export async function generateStaticParams() {
return await mainAll.pipe(Effect.runPromise);
}
export default async function Page({
params: { slug },
}: {
params: { slug: string };
}) {
const { source, tableOfContents, frontmatter, readingTime } = await main(
slug
).pipe(
Effect.catchAll(() => Effect.sync(() => redirect("/articles"))),
Effect.runPromise
);
return ( /** */ );
}Each page then uses getArticleBySlug from ContentService to extract the content of each single article:
const main = (slug: string) =>
Effect.gen(function* (_) {
const contentService = yield* _(Content.ContentService);
return yield* _(
contentService.getArticleBySlug({ slug })
);
}).pipe(Effect.provide(Content.ContentServiceLive));
const mainAll = Effect.gen(function* (_) {
const contentService = yield* _(Content.ContentService);
return yield* _(
contentService.getAllArticles,
Effect.map(ReadonlyArray.map((s) => ({ slug: s.slug })))
);
}).pipe(Effect.provide(Content.ContentServiceLive));
export async function generateStaticParams() {
return await mainAll.pipe(Effect.runPromise);
}
export default async function Page({
params: { slug },
}: {
params: { slug: string };
}) {
const { source, tableOfContents, frontmatter, readingTime } = await main(
slug
).pipe(
Effect.catchAll(() => Effect.sync(() => redirect("/articles"))),
Effect.runPromise
);
return ( /** */ );
}I then provide all the content to the page, or redirect to the index /articles if the slug is invalid:
const main = (slug: string) =>
Effect.gen(function* (_) {
const contentService = yield* _(Content.ContentService);
return yield* _(
contentService.getArticleBySlug({ slug })
);
}).pipe(Effect.provide(Content.ContentServiceLive));
const mainAll = Effect.gen(function* (_) {
const contentService = yield* _(Content.ContentService);
return yield* _(
contentService.getAllArticles,
Effect.map(ReadonlyArray.map((s) => ({ slug: s.slug })))
);
}).pipe(Effect.provide(Content.ContentServiceLive));
export async function generateStaticParams() {
return await mainAll.pipe(Effect.runPromise);
}
export default async function Page({
params: { slug },
}: {
params: { slug: string };
}) {
const { source, tableOfContents, frontmatter, readingTime } = await main(
slug
).pipe(
Effect.catchAll(() => Effect.sync(() => redirect("/articles"))),
Effect.runPromise
);
return ( /** */ );
}MDX to html: next-mdx-remote
MDX allows to define some custom react components to reuse in the articles.
next-mdx-remote supports server components using next-mdx-remote/rsc. Just provide the components and the source content (string) and everything just works:
import { MDXRemote } from "next-mdx-remote/rsc";
const components = { /** */ };
export function CustomMDX({ source }: { source: string }) {
return (
<MDXRemote
source={source}
components={components}
options={{
mdxOptions: {
// Plugins 👈
},
}}
/>
);
}That's all!
Few dependencies, all local, easy to maintain, fast and SEO optimized 🚀
I like to have full control over my content, while also keeping costs at the minimum (both maintenance and infrastructure).
It's super easy to start your personal blog. Highly recommended!
If you are interested to learn more, every week I publish a new open source project and share notes and lessons learned in my newsletter. You can subscribe here below 👇
Thanks for reading.
