Using Jupyter notebooks as MDX blog posts in Next.js


Next.js has really good DX for building websites using Markdown. With @next/mdx, you can simply drop a markdown file in your project, the markdown content gets converted into React, and it acts as if it were a regular page.tsx.

I wanted a similar experience with ipynb. My previous workflow to write blog posts using ipynb was to convert the ipynb into markdown using hand written scripts or something like pandoc. We can take advantage of the same magic behind "use workflow" and mdx to turn ipynb into Next.js pages.

your-project
  ├── src/
  │   ├── mdx-components.tsx
  |   ├── page.tsx
  │   └── posts/
  │       ├── my-post.mdx
  │       └── my-ipynb-post.ipynb
  └── next.config.ts

Adding a new loader to next.config.ts

Webpack and Turbopack need rules in next.config.ts to compile *.ipynb files. If a file matches a rule's filter (in this case, *.ipynb), it applies a function that converts the file into a Javascript module.

First, we define the rule.

const mdxLoader = {
  loader: require.resolve("@next/mdx/mdx-js-loader"),
  options: {
    providerImportSource: "next-mdx-import-source-file",
    remarkPlugins: ["remark-math", "remark-gfm"],
    rehypePlugins: ["rehype-katex"],
  },
};

const projectRoot = path.dirname(fileURLToPath(import.meta.url));
const ipynbToMdxLoader = path.join(projectRoot, "loaders/ipynb-to-mdx.cjs");

const ipynbRule = {
  loaders: [mdxLoader, ipynbToMdxLoader],
  as: "*.tsx",
};

Then wire that rule into both bundlers in the NextConfig.

const nextConfig: NextConfig = {
  pageExtensions: ["js", "jsx", "md", "mdx", "ts", "tsx"],
  reactCompiler: false,
  turbopack: {
    rules: {
      "*.ipynb": ipynbRule,
    },
  },
  webpack(config, options) {
    config.module.rules.push({
      test: /\.ipynb$/,
      use: [options.defaultLoaders.babel, mdxLoader, ipynbToMdxLoader],
    });

    return config;
  },
};

When the app imports a notebook (for example, import("@/post.ipynb")) — the bundler matches *.ipynb, runs the loader chain, and the module exports { default: Post, metadata } just like an MDX file.

export default async function Page({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  // slug can be an ipynb, md, or mdx
  const { default: Post, metadata } = await importPost(slug);

  return (
    <>
      <h1>
        {metadata.title}
      </h1>
      <Post />
    </>
  );
}

The ipynb loader

A raw Python jupyter notebook lookes like this:

{
  "cells": [
    { "cell_type": "markdown", "source": ["## Hello\n"] },
    {
      "cell_type": "code",
      "source": ["print(\"hi\")\n"],
      "outputs": [{ "output_type": "stream", "name": "stdout", "text": ["hi\n"] }]
    }
  ],
  "metadata": {
    "post": { "title": "My post", "date": "2026-05-26" },
    "language_info": { "name": "python" }
  },
  "nbformat": 4,
  "nbformat_minor": 5
}

loaders/ipynb-to-mdx.cjs is a function that receives the raw file as a string, JSON.parses it, and formats it as a Markdown file.

module.exports = function ipynbToMdx(source) {
  const resourcePath = this.resourcePath ?? "notebook.ipynb";
  let notebook;
  try {
    notebook = JSON.parse(source);
  } catch (cause) {
    throw new Error(`${resourcePath}: invalid JSON notebook`, { cause });
  }

  return convertNotebook(notebook, resourcePath);
};