MDX w Astro i Next.js: jak pisać artykuły z komponentami React w treści

Opublikowano
24 kwietnia 2026
Aktualizacja
24 czerwca 2026
Czas czytania
4 min czytania

to format, który łączy Markdown z komponentami JSX. W praktyce oznacza to, że w środku artykułu możesz wstawić interaktywny kalkulator, animowaną grafikę, niestandardową ramkę z ostrzeżeniem albo ankietę — bez opuszczania pliku z treścią.

Czym różni się MDX od Markdown

Markdown to czysty tekst ze składnią dla nagłówków, list, pogrubień, linków i obrazów. MDX dodaje dwie rzeczy:

  1. Komponenty w treści — możesz napisać <Callout variant="warning">Uwaga!</Callout> w środku akapitu i to się wyrenderuje jako prawdziwy komponent.
  2. Import innych komponentów — w górze pliku możesz zaimportować React/Vue/Svelte component i użyć go jako JSX.

Przykład MDX:

Code
---
title: 'Mój wpis o Astro'
date: 2026-04-24
---
 
import Callout from '../../components/Callout.astro'
import PriceCalculator from '../../components/PriceCalculator.tsx'
 
# Astro jest szybki
 
Ale nie zawsze to oznacza, że jest dla Ciebie.
 
<Callout variant="info">
  Ten artykuł zakłada, że znasz podstawy HTML i CSS.
</Callout>
 
## Oblicz koszt projektu
 
<PriceCalculator client:visible />
 
Reszta artykułu wraca do zwykłego Markdowna. **Pogrubienie**, _kursywa_, [linki](https://strivelab.pl) — wszystko działa jak w MD.

Wynik: artykuł, w którym masz zarówno zwykłą treść, jak i niestandardowy komponent informacyjny i interaktywny kalkulator.

Jak skonfigurować MDX w Astro i Next.js

W skrócie: w Astro MDX włączasz jedną komendą astro add mdx, w Next.js instalujesz cztery paczki, konfigurujesz createMDX w next.config.mjs i dodajesz wymagany plik mdx-components.tsx. Tu zaczyna się pierwsza różnica między frameworkami.

W Astro instalacja to jedna komenda:

Code
npx astro add mdx

To instaluje @astrojs/mdx i wpisuje integrację mdx() do astro.config.mjs. Typy dla .mdx dostajesz automatycznie przez astro/client, więc nie musisz ręcznie ruszać tsconfig.json. Pliki .mdx w src/pages/ automatycznie stają się stronami (file-based routing), a pliki w src/content/ można wciągnąć do — tak, jak opisałem w artykule o Content Collections.

W Next.js (App Router) instalujesz cztery paczki i konfigurujesz next.config.mjs przez createMDX:

Code
npm install @next/mdx @mdx-js/loader @mdx-js/react @types/mdx
Code
// next.config.mjs
import createMDX from '@next/mdx'
 
/** @type {import('next').NextConfig} */
const nextConfig = {
  pageExtensions: ['js', 'jsx', 'md', 'mdx', 'ts', 'tsx'],
}
 
const withMDX = createMDX()
 
export default withMDX(nextConfig)

Plus jeden krok, którego w Astro nie ma: mdx-components.tsx w korzeniu projektu jest wymagany — bez niego @next/mdx z App Routerem po prostu nie zadziała:

Code
// mdx-components.tsx
import type { MDXComponents } from 'mdx/types'
 
const components: MDXComponents = {}
 
export function useMDXComponents(): MDXComponents {
  return components
}

Różnica filozoficzna: w Astro .mdx to obywatel pierwszej klasy z pudełka, w Next.js MDX jest wpinany jako rozszerzenie kompilatora — stąd więcej kroków, ale i głębsza integracja z React Server Components.

Frontmatter w MDX: Astro natywnie, Next.js przez wtyczkę

MDX w Astro używa YAML — taki sam jak w zwykłym Markdownie. Pola dostępne są później jako frontmatter (w page mode) lub data (w Content Collections).

Code
---
title: 'Mój pierwszy wpis'
description: 'Opis dla SEO, 50-160 znaków.'
date: 2026-04-24
tags: ['astro', 'poradnik']
---
 
# {frontmatter.title}
 
Treść artykułu...

Zwróć uwagę na {frontmatter.title} — w MDX można używać wyrażeń JavaScript w ciałopodobnej składni JSX. To potężne narzędzie, bo pozwala DRY-ować treść (tytuł z frontmatteru użyty automatycznie).

W kontekście Content Collections rekomenduję jednak trzymać title w szablonie (<h1>{post.data.title}</h1> w [...slug].astro) i w MDX nie duplikować go. Inaczej łatwo o rozjazd.

Jakie komponenty React warto trzymać w blogu MDX

Z doświadczenia — kilka komponentów zarabia na siebie w każdym blogu MDX. Oto te, które trzymam pod ręką.

Callout / Notice

Ramka z ostrzeżeniem, informacją lub wskazówką:

Code
---
// src/components/Callout.astro
type Variant = 'info' | 'warning' | 'danger' | 'tip';
 
interface Props {
  variant?: Variant;
}
 
const { variant = 'info' } = Astro.props;
 
const styles: Record<Variant, string> = {
  info: 'bg-blue-50 border-blue-400 text-blue-900',
  warning: 'bg-yellow-50 border-yellow-400 text-yellow-900',
  danger: 'bg-red-50 border-red-400 text-red-900',
  tip: 'bg-green-50 border-green-400 text-green-900',
};
---
 
<div class={`border-l-4 p-4 my-6 rounded ${styles[variant]}`}>
  <slot />
</div>

Nazwa propsa (variant) i wartości muszą zgadzać się z wywołaniem w MDX — to najczęstsze źródło „dlaczego mój Callout zawsze wygląda tak samo": komponent czyta jedną nazwę, a artykuł podaje inną, więc zawsze ląduje na wartości domyślnej.

Użycie w MDX:

Code
<Callout variant="warning">
  Upewnij się, że masz Node 22+ przed migracją na Astro 6.
</Callout>

Code with title

Blok kodu z etykietą/tytułem:

Code
---
// src/components/CodeBlock.astro
const { filename } = Astro.props;
---
 
<div class="code-block">
  {filename && <div class="code-filename">{filename}</div>}
  <slot />
</div>
Code
<CodeBlock filename="astro.config.mjs">
 
```js
export default defineConfig({
  integrations: [mdx()],
})
```
 
</CodeBlock>

Obraz z opisem

Responsywny obraz z podpisem:

Code
---
// src/components/Figure.astro
import { Image } from 'astro:assets';
 
const { src, alt, caption } = Astro.props;
---
 
<figure class="my-6">
  <Image src={src} alt={alt} loading="lazy" />
  {caption && <figcaption class="text-sm text-gray-600 mt-2">{caption}</figcaption>}
</figure>
Code
<Figure
  src={import('../../assets/islands-diagram.png')}
  alt="Diagram architektury wysp w Astro"
  caption="Zielone prostokąty to statyczny HTML, pomarańczowe — wyspy JS."
/>

Interaktywne elementy

Realny przykład — prosty kalkulator osadzony w artykule o cenach:

Code
// src/components/CostCalculator.tsx
// Uwaga: w Astro NIE używasz 'use client' (to konwencja Next.js).
// Interaktywność włączasz dyrektywą client:* w miejscu użycia komponentu.
import { useState } from 'react'
 
export default function CostCalculator() {
  const [hours, setHours] = useState(10)
  const rate = 200
 
  return (
    <div className="my-6 rounded border p-4">
      <label>
        Liczba godzin:
        <input
          type="number"
          value={hours}
          onChange={(e) => setHours(+e.target.value)}
          className="ml-2 rounded border px-2 py-1"
        />
      </label>
      <p className="mt-2">
        Szacowany koszt:{' '}
        <strong>{(hours * rate).toLocaleString('pl-PL')} zł</strong>
      </p>
    </div>
  )
}
Code
import CostCalculator from '../../components/CostCalculator.tsx'
 
Ile realnie kosztuje audyt SEO? Oblicz sam:
 
<CostCalculator client:visible />

Komponent React dopiero, kiedy user doscrolluje — reszta artykułu zostaje statyczna. Jeśli temat dyrektyw hydracji jest Ci obcy, zajrzyj do artykułu o client directives.

Pamiętaj też, że w Astro 5+ React działa w wersji 19 — jeśli przenosisz komponenty ze starszego projektu, zweryfikuj zgodność bibliotek (część ekosystemu wciąż dogania React 19).

A jak to wygląda w Next.js? Dokładnie odwrotnie. Tu wszystkie komponenty są domyślnie serwerowe (React Server Components), a interaktywność włączasz dyrektywą 'use client' na górze pliku komponentu — nie przy jego użyciu w MDX:

Code
// components/CostCalculator.tsx
'use client'
import { useState } from 'react'
 
export default function CostCalculator() {
  const [hours, setHours] = useState(10)
  // ...identyczne ciało jak wyżej
}
Code
import CostCalculator from '@/components/CostCalculator'
 
Ile realnie kosztuje audyt SEO? Oblicz sam:
 
<CostCalculator />

To fundamentalna różnica jest w modelu mentalnym: Astro jest statyczny domyślnie i interaktywność dokładasz punktowo w MDX (client:*), a Next.js jest serwerowy domyślnie i interaktywność deklarujesz w pliku komponentowym (np.'use client'). Efekt jest podobny, ale osiąga się go inaczej (minimum JavaScriptu w przeglądarce). Komponenty z hookami przeglądarki w Next.js też muszą być klienckie; odpowiednikiem Astrowego client:only jest tu po prostu komponent z 'use client', który nie renderuje nic zależnego od window podczas SSR (albo next/dynamic z ssr: false).

Jak zmapować domyślne elementy HTML na własne komponenty (custom mapping)

MDX pozwala zmapować standardowe elementy HTML (np. <h2>, <a>, <img>) na własne komponenty. Używam tego do:

  • Dodawania automatycznych „anchor links" do nagłówków (kotwica obok każdego <h2>).
  • Otwierania linków zewnętrznych w nowej karcie z rel="noopener".
  • Zamiany <img> na <Image> z astro:assets dla automatycznej optymalizacji.

Ustawiasz to w layoucie artykułu:

Code
---
// src/layouts/BlogPost.astro
import AnchoredHeading from '../components/AnchoredHeading.astro';
import ExternalLink from '../components/ExternalLink.astro';
import OptimizedImage from '../components/OptimizedImage.astro';
 
const components = {
  h2: AnchoredHeading,
  h3: AnchoredHeading,
  a: ExternalLink,
  img: OptimizedImage,
};
 
const { post } = Astro.props;
const { Content } = await render(post);
---
 
<article>
  <Content components={components} />
</article>

Teraz każdy ## Nagłówek w Twoich artykułach MDX automatycznie dostaje kotwicę, każdy link zewnętrzny — target="_blank", a każdy obraz — optymalizację.

Astro nie ma wbudowanego mechanizmu, który nałożyłby to mapowanie na wszystkie pliki MDX narazcomponents przekazujesz do <Content /> przy renderowaniu. Wzorzec dla bazy z dziesiątkami artykułów nie należy do skomplikowanych, ponieważ opiera się na zdefiniowanej mapie globalnej raz w jednym wspólnym layoucie (jak wyżej) i renderowaniu przez niego każdego wpisu. Wtedy ustawienie obowiązuje wszędzie, bo wszystkie artykuły idą tą samą ścieżką renderu.

W Next.js to akurat działa wygodniej. Mapę globalną wpisujesz raz do mdx-components.tsx (tego samego pliku, który i tak jest wymagany), a Next.js stosuje ją automatycznie do każdego pliku MDX bez przekazywania czegokolwiek per-strona:

Code
// mdx-components.tsx
import type { MDXComponents } from 'mdx/types'
import Image, { type ImageProps } from 'next/image'
import { AnchoredHeading } from '@/components/AnchoredHeading'
 
const components: MDXComponents = {
  h2: AnchoredHeading,
  h3: AnchoredHeading,
  img: (props) => <Image sizes="100vw" {...(props as ImageProps)} />,
}
 
export function useMDXComponents(): MDXComponents {
  return components
}

Mapowanie lokalne (override dla jednej strony) nadal przekazujesz przez prop components do importowanego komponentu MDX, a potem scala się ono z globalnym. To jedno z miejsc, gdzie Next.js wyprzedza Astro.

Optymalizacja obrazów w MDX

Obrazy w MDX optymalizujesz przez <Image> z astro:assets (Astro) lub next/image (Next.js) zamiast surowego <img> — oba generują WebP/AVIF, responsywny srcset, leniwe ładowanie i wymuszają width/height, żeby uniknąć CLS. To obszar, gdzie MDX w Astro ma przewagę nad większością innych setupów, ponieważ zamiast pisać <img src="..."> (brak optymalizacji), używasz <Image>:

Code
import { Image } from 'astro:assets'
import heroImage from '../../assets/astro-hero.jpg'
 
<Image src={heroImage} alt="Astro logo" width={1200} height={630} />

Astro w build time:

  • Generuje wersje w WebP i AVIF.
  • Tworzy responsywne warianty (srcset).
  • Dodaje loading="lazy" dla obrazów poniżej viewportu.
  • Wstawia explicit width i height, żeby uniknąć .

Dla strony z 80+ artykułami i po kilka obrazów na każdy, automatyczna optymalizacja to różnica między 5 MB a 500 KB ładowanego contentu. Poważnie wpływa na Core Web Vitals.

W Next.js dostajesz to samo przez next/image — z tą wygodą, że mapując img w mdx-components.tsx (jak wyżej) zamieniasz każdy ![alt](src) w treści na zoptymalizowany <Image>, bez ruszania pojedynczych artykułów:

Code
import Image from 'next/image'
import heroImage from '@/assets/hero.jpg'
 
<Image src={heroImage} alt="Logo" width={1200} height={630} />

next/image generuje WebP/AVIF, responsywny srcset, leniwe ładowanie i wymusza width/height — dokładnie po to samo, co astro:assets: żeby nie płacić i wagą obrazów.

Typowana baza treści — Content Collections (Astro) i jej odpowiednik w Next.js

Najlepszy setup dla bloga to MDX z walidowanym, typowanym frontmatterem. W Astro to Content Collections. Wtedy masz:

  • Pliki .mdx w src/content/blog/.
  • Schemat Zod walidujący frontmatter.
  • Typowane pole data w szablonie.
  • Automatyczne generowanie stron dla każdego artykułu.

Konfiguracja:

Code
// src/content.config.ts
import { defineCollection } from 'astro:content'
import { glob } from 'astro/loaders'
import { z } from 'astro/zod'
 
const blog = defineCollection({
  loader: glob({ pattern: '**/*.mdx', base: './src/content/blog' }),
  schema: z.object({
    title: z.string().max(80),
    description: z.string().min(50).max(160),
    date: z.coerce.date(),
    tags: z.array(z.string()),
    image: z.string().optional(),
  }),
})
 
export const collections = { blog }

Dynamiczna strona:

Code
---
// src/pages/blog/[...slug].astro
import { getCollection, render } from 'astro:content';
import BlogLayout from '../../layouts/BlogLayout.astro';
 
export async function getStaticPaths() {
  const posts = await getCollection('blog');
  return posts.map((post) => ({
    params: { slug: post.id },
    props: { post },
  }));
}
 
const { post } = Astro.props;
const { Content } = await render(post);
---
 
<BlogLayout post={post}>
  <Content />
</BlogLayout>

Teraz każdy plik MDX dodany do src/content/blog/ automatycznie staje się stroną pod /blog/[nazwa-pliku]/ bez żadnej dodatkowej konfiguracji.

Next.js nie ma tego z pudełka — i to jest największa różnica w warstwie treści. Masz trzy ścieżki, od najprostszej do najbardziej zintegrowanej:

  • Ręcznie: fs/globby czyta pliki, gray-matter parsuje frontmatter, a Ty walidujesz go własnym schematem Zod i renderujesz w app/blog/[slug]/page.tsx przez generateStaticParams. Najwięcej kontroli, najwięcej kodu.
  • content-collections (@content-collections/mdx) — biblioteka, która daje to, co Astrowe Content Collections: typowany, walidowany Zodem frontmatter i wygodne API do listy wpisów.
  • Contentlayer — popularny historycznie, ale na 2026 jego utrzymanie jest niepewne; do nowych projektów rozważ go ostrożnie.

Schemat Zod wygląda identycznie jak w Astro — różni się tylko to, kto wczytuje pliki: w Astro robi to framework, w Next.js Ty (albo wybrana biblioteka). Mechanizm walidacji i typów jest ten sam.

Pułapki i ograniczenia MDX, o których warto wiedzieć

1. MDX to nie jest „Markdown + HTML". To Markdown + JSX. Niektóre rzeczy wyglądające jak HTML, kompilują się jak JSX — classclassName, <br><br />, self-closing tagi. Jeśli kopiujesz HTML z innych źródeł, musisz uważać.

2. Nazwa pliku a import. W MDX importy działają względem lokalizacji pliku MDX, nie względem szablonu, który go renderuje. Jeśli przeniesiesz plik MDX do podfolderu, ścieżki importów się rozsypią.

3. Duże MDX = wolne buildy. Jeśli masz plik MDX z 50+ osadzonymi komponentami i tysiącami linii treści, buildy mogą dramatycznie zwolnić. W praktyce nie spotkałem tego problemu przy rozsądnym użyciu (3-5 osadzonych komponentów na artykuł), ale warto mieć to z tyłu głowy.

4. HMR ma limity. W dev mode edycja frontmatteru odświeża stronę w pełni, edycja treści zazwyczaj działa hot. Edycja importowanych komponentów — refresh jest konieczny.

5. Syntax highlighting. Astro używa dla bloków kodu w wersji standardowej. W Next.js dokładasz go jako wtyczkę rehype (rehype-pretty-code oparte o Shiki) w createMDX. W obu przy bardzo dużych artykułach z dziesiątkami bloków kodu rośnie czas buildu: w Astro pomaga expressive-code (lepsze caching), w Next.js warto trzymać highlighting na etapie buildu, a nie runtime.

Standardowy zestaw komponentów MDX dla bloga

W moich projektach blogowych mam standardowy zestaw komponentów:

  • <Callout> — ramka z info / warning / tip,
  • <Figure> — obraz z podpisem i optymalizacją,
  • <VideoEmbed> — lazy-loaded embed YouTube/Vimeo,
  • <CodeBlock> — kod z nazwą pliku i przyciskiem „Copy",
  • <TableOfContents> — automatyczny spis treści,
  • <RelatedArticles> — komponent z powiązanymi artykułami (przekazywany z szablonu).

Jest ich więcej, ale stosuje je w różnych konfiguracjach. Żaden z nich nie jest przesadnie skomplikowany, a każdy to 20–50 linii. Wszystkie razem sprawiają, że piszę artykuły dwa, trzy razy szybciej, a do tego spójniej.

Zasady użycia komponentów React w treści MDX

MDX bardzo łatwo zmienić w przypadkową aplikację ukrytą w artykule. Żeby tego uniknąć, trzymam się kilku zasad:

  • Komponent w MDX powinien wzmacniać zrozumienie treści, a nie zastępować normalny akapit. Nie wciskaj nic na siłę.
  • Interaktywny komponent musi mieć jasny powód biznesowy albo edukacyjny. Wszystko musi wynikać z artykułu.
  • Komponenty powtarzalne trzymaj globalnie, a nie jako jednorazowe importy w każdym wpisie.
  • Nie osadzaj ciężkich bibliotek w artykule bez lazy loadingu (client:visible w Astro, next/dynamic w Next.js).
  • Każdy custom component musi mieć sensowną wersję bez JavaScriptu albo fallback.
Ultraszybkie projekty, łączące lekkość ze skalowalnością.
Astro

Często zadawane pytania

Czym różni się MDX 2 od MDX 3?

MDX 3 (używany w Astro 5+) ma lepszą obsługę komentarzy w JSX, poprawione błędy kompilacji i wydajniejszy parser. Dla typowego projektu blogowego różnica jest niewidoczna, ale MDX 3 jest niezawodny w złożonych przypadkach z mieszanym Markdown/JSX.

Tak — Astro obsługuje wiele frameworków jednocześnie. Dla komponentów interaktywnych w artykułach trzymaj się jednego frameworka: ułatwia utrzymanie, debugging i zarządzanie bundle'em.

MDX kompiluje się do statycznego HTML w build time. Dla użytkownika końcowego nie ma różnicy względem Markdowna — chyba że osadzasz interaktywne komponenty. Wtedy koszt to bundle komponentu, nie samego MDX-a.

Zmień rozszerzenie z .md na .mdx. Większość plików Markdown jest poprawnym MDX. Potencjalne konflikty: znaki < i { w treści mogą być interpretowane jako JSX. Po zmianie rozszerzeń przejdź po wszystkich plikach i uruchom astro build — błędy pojawią się natychmiast.

Tak, po włączeniu remark-gfm w konfiguracji Markdown/MDX. Wtedy działają tabele, checklisty, strikethrough i pozostałe rozszerzenia GitHub Flavored Markdown.

Gdy komponent w trakcie renderu sięga po API przeglądarki (window, document) albo używa biblioteki bez wsparcia SSR. Przy client:visible Astro renderuje go też na serwerze, co wywala build. client:only="react" pomija SSR i renderuje wyłącznie w przeglądarce — kosztem braku treści w HTML do czasu hydracji, więc nie używaj go dla treści ważnej dla SEO.

W Astro nie ma globalnego mapowania dla wszystkich plików MDX — obiekt components przekazujesz do <Content /> przy renderowaniu, a praktyczny wzorzec to zdefiniowanie mapy raz we wspólnym layoucie. W Next.js jest odwrotnie: globalną mapę wpisujesz raz do mdx-components.tsx i obowiązuje ona automatycznie we wszystkich plikach MDX, a mapowanie lokalne przekazane przez prop components scala się z globalnym.

Rdzeń (Markdown + JSX, importy komponentów, custom mapping) jest identyczny — różni się integracja. Astro: instalacja jedną komendą astro add mdx, natywny frontmatter, Content Collections z walidacją Zod i optymalizacja obrazów (astro:assets) z pudełka, interaktywność per użycie przez client:*. Next.js: @next/mdx z wymaganym plikiem mdx-components.tsx, brak natywnego frontmatteru (remark-frontmatter albo gray-matter), własna warstwa treści lub content-collections, next/image, interaktywność per komponent przez 'use client'. Globalne mapowanie komponentów jest wygodniejsze w Next.js, a szybki start z typowanym blogiem — w Astro.

Tak, jeśli komponent używa stanu, efektów albo API przeglądarki. W App Routerze wszystkie komponenty są domyślnie serwerowe (React Server Components), więc interaktywny widget oznaczasz dyrektywą 'use client' na górze jego pliku. To odwrotność modelu Astro, gdzie komponent jest statyczny, dopóki nie dodasz dyrektywy client:* w miejscu jego użycia w MDX.

O autorze

Maciej Sala

Maciej Sala — Product Manager i Frontend Developer z bogatym doświadczeniem w marketingu internetowym oraz SEO. Na co dzień pracuje z Reactem, Next.js i TypeScriptem, a ostatnio także z Astro i narzędziami do automatyzacji procesów AI. Sprawnie łączy perspektywę produktową z praktycznym podejściem do kodu. Przez kilka lat był związany z branżą gier wideo jako project manager i game designer. Absolwent historii na Uniwersytecie Jagiellońskim oraz studiów podyplomowych z marketingu internetowego na AGH w Krakowie. Po godzinach trenuje na siłowni, maluje figurki i rozwija własne projekty side-projecty.

Pomagam przekładać takie tematy na konkretne wdrożenia w frontendzie, SEO, analityce i procesie produktowym.

Skontaktuj się ze mną

Biblioteka wiedzy

Czytaj dalej

Zobacz więcej wpisów