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 theapp
directory, server components, andgenerateStaticParams
to 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 formattingtailwindcss
rehype
plugins
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
frontmatter
with all the article's metadata (e.g. title, description, category)
Screenshot of my local articles folder containing all the articles in mdx format
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:
path
andfs
π€
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 π€©
Timeless coding principles, practices, and tools that make a difference, regardless of your language or framework, delivered in your inbox every week.
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 slug
s 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.