Create a blog with Effect, NextJs and MDX

Sandro Maglione

Sandro Maglione

Web development

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 the app directory, server components, and generateStaticParams to generate article pages at build time
  • MDX: All the articles are written using mdx, and then rendered in html using next-mdx-remote
package.json
"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 highlighting
  • date-fns: Dates formatting
  • tailwindcss
  • 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 formatScreenshot 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:

content-service.ts
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 and fs 🤝

An Effect is responsible to get all the files from the local data/articles directory, extract all metadata, and return all published articles:

content-service.ts
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 build a new open source project, with a new language or library, and teach you how I did it, what I learned, and how you can do the same. Join me and other 600+ readers.

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:

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.

page.tsx
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:

page.tsx
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:

page.tsx
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:

page.tsx
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.

👋・Interested in learning more, every week?

Every week I build a new open source project, with a new language or library, and teach you how I did it, what I learned, and how you can do the same. Join me and other 600+ readers.