banner image

Next Intl + mdx = awesome

How to combine markdown and next-intl for translating large pieces of static page data

By Marius

Last updated May 3, 2025

I've been working with Next-intl for a while now. Using markdown for larger amount of static text content like privacy policies is awesome. You write up the English variant first then ask ChatGPT to translate the document into all the languages you desire.

src
└── markdown
    └── privacy-policy
        ├── en.mdx
        ├── no.mdx
        └── de.mdx

Then you import it into your page.tsx, like seen below:

import { setRequestLocale } from "next-intl/server";
import Heading from "@/src/components/Shared/Elements/Heading";
import { getTranslations } from "next-intl/server";

const page = async ({
  params: { locale },
}: {
  params: { locale: string };
}) => {
  setRequestLocale(locale);
  const t = await getTranslations();
  const Article = (await import(`@/src/markdown/privacy-policy/${locale}.mdx`))
    .default;
  return (
    <div>
      <Heading>{t("Global.Privacy policy")}</Heading>
      <article>{Article && <Article />}</article>
    </div>
  );
};

export default page;

Getting SSG right with next-intl

The key to getting fully static HTML per locale is generateStaticParams paired with setRequestLocale. In the [locale]/layout.tsx you define which locales to pre-render:

export function generateStaticParams() {
  return [{ locale: "no" }, { locale: "en" }];
}

Then every page and layout under [locale] needs to call setRequestLocale(locale) early — before any other next-intl API like getTranslations or getMessages. This tells next-intl that the locale is known at build time and it doesn't need to read it from headers or cookies (which would force dynamic rendering).

The things you want to avoid on static pages:

  • No cookies(), headers(), or draftMode() in the render tree
  • No fetch(..., { cache: 'no-store' }) during render
  • No export const dynamic = 'force-dynamic'

Any of these will flip your route from ○ (Static) to ƒ (Dynamic) in the build output. You can verify this after running next build — if everything is set up correctly, you should see your markdown pages listed as static.

No sanitizing

One thing to keep in mind is that there is no sanitizing going on here. Given that the source is not unknown and we're just parsing a local file to HTML, there's no danger of XSS attacks. If you do fetch these files remotely from sources beyond your control, you can add plugins like rehype-sanitize to the next.config.mjs file, which will remove any potencial malicious HTML, like script tags.

Example:

// next.config.mjs
import createMDX from "@next/mdx";
import rehypeSanitize from "rehype-sanitize";

const withMDX = createMDX({
  options: {
    rehypePlugins: [rehypeSanitize],
  },
});

export default withMDX({
  pageExtensions: ["js", "jsx", "ts", "tsx", "md", "mdx"],
});

Styling the parsed content

You don't need to write a bunch of CSS to style the parsed markdown. Next.js MDX has native support for an mdx-components.tsx file at the root of your project. This file lets you map every HTML element that the markdown compiles to — headings, paragraphs, links, code blocks, etc. — to your own React components with Tailwind classes.

// mdx-components.tsx
import type { MDXComponents } from "mdx/types";

export function useMDXComponents(components: MDXComponents): MDXComponents {
  return {
    h1: ({ children }) => (
      <h1 className="text-3xl font-bold mt-6 mb-4">{children}</h1>
    ),
    h2: ({ children }) => (
      <h2 className="text-2xl font-bold mt-6 mb-3">{children}</h2>
    ),
    p: ({ children }) => <p className="mb-4 leading-relaxed">{children}</p>,
    a: ({ children, href }) => (
      <a href={href} className="text-blue-600 hover:underline">
        {children}
      </a>
    ),
    ul: ({ children }) => <ul className="list-disc ml-5 mb-4">{children}</ul>,
    code: ({ children }) => (
      <code className="bg-slate-200 px-1 py-0.5 rounded text-sm">
        {children}
      </code>
    ),
    blockquote: ({ children }) => (
      <blockquote className="border-l-4 border-gray-300 pl-4 italic text-gray-600">
        {children}
      </blockquote>
    ),
    ...components,
  };
}

This way all your styling lives in one place and stays consistent with the rest of your Tailwind setup. No separate stylesheet needed.