MDX
An alternative way to author content is using .mdx files (Markdown JSX). These files are authored as Markdown, but they are compiled down to Qwik components. In addition to Markdown syntax, .mdx files can also refer to other components.
Let's assume you have your routes set up like this:
src/
โโโ routes/
โโโ some/
โโโ path/
โโโ index.mdx # https://example.com/some/path
---
title: Hello World Title
---
This is a simple hello world component.
The above component will be rendered at https://example.com/some/path.
Importing other components.
MDX is a creative opportunity for you to come up with new content quickly ("Qwikly" ๐) and if you need more interaction on your page you can seamlessly integrate your Qwik components like so:
src/
โโโ components/
| โโโ counter
โ โโโ counter.tsx
โโโ routes/
โโโ some/
โโโ path/
โโโ index.mdx # https://example.com/some/path
---
title: Hello World Title
---
import { Counter } from "../../../components/counter/counter";
This is a simple hello world component.
<Counter />
import { component$, useSignal } from '@builder.io/qwik';
export const Counter = component$(() => {
const count = useSignal(0);
return (
<button class="counter" type="button" onClick$={() => count.value++}>
Increment {count.value}
</button>
);
});
Note: A key difference between Qwik City and many current meta-frameworks is directory-based routing. Every route needs to be defined as a-directory/index.(tsx,ts,js,jsx,md,mdx).
In other meta-frameworks you're used to about.mdx will render a route http://example.com/about. However, this will not work in Qwik City. You must rename the file to about/index.mdx for Qwik City to know to render it.
Disabling default MDX plugins included.
Qwik City includes 3 plugins by default.
- remarkGfm: GFM support (autolink literals, footnotes, strikethrough, tables, tasklists).
- rehypeSyntaxHighlight: Lightweight, robust, elegant virtual syntax highlighting using Prism.
- rehypeAutolinkHeadings: Plugin to add links to headings in HTML.
These plugins can be disabled independently in the following way:
import { defineConfig } from 'vite';
import { qwikCity } from '@builder.io/qwik-city/vite';
// See below
import rehypeAutolinkHeadings from 'rehype-autolink-headings';
export default defineConfig(() => {
return {
plugins: [
qwikCity({
mdxPlugins: {
remarkGfm: false,
rehypeSyntaxHighlight: false,
rehypeAutolinkHeadings: false,
},
mdx: {
rehypePlugins: [
// Plugins can now be added manually to use a different configuration
[rehypeAutolinkHeadings, { behavior: 'wrap' }],
],
},
}),
/* the rest of the configuration */
],
};
});
Open Graph Properties
You can use og or opengraph property to define Open Graph protocol metadata.
title: My Title
description: My Description
og:
- title: My Custom Title
description: true
- image: https://example.com/rock.jpg
image:alt: A shiny red apple with a bite taken out
- image: https://example.com/rock2.jpg
Setting og:title or og:description to true will check and use outside title and description property instead. Thus, you can avoid writing a same title and description twice.
The above example will generate the following HTML code.
<title>My Title</title>
<meta name="description" content="My Description" />
<meta property="og:title" content="My Custom Title" />
<meta property="og:description" content="My Description" />
<meta property="og:image" content="https://example.com/rock.jpg" />
<meta property="og:image:alt" content="A shiny red apple with a bite taken out" />
<meta property="og:image" content="https://example.com/rock2.jpg" />
Reading frontmatter data
Frontmatter keys are accessible by leveraging the useDocumentHead() hook.
---
title: Hello World Title
tags:
- super
- exiting
- docs
---
import { Tags } from "../../../components/tags/tags";
This is a simple hello world component.
<Tags />
import { component$ } from '@builder.io/qwik';
import { useDocumentHead } from '@builder.io/qwik-city';
export const Tags = component$(() => {
const { frontmatter } = useDocumentHead();
if (frontmatter.tags.length === 0) {
return null;
}
return (
<ul>
{frontmatter.tags.map((tag: string) => (
<li key={`tag-${tag}`}>{tag}</li>
))}
</ul>
);
});
useContent()
The useContent() function retrieves the nearest content information for the current route. The returned object includes:
headings: ContentHeading[] | undefined;
menu: ContentMenu | undefined;
The headings array includes data about a markdown file's <h1> to <h6> html heading elements.
Menus are contextual data declared with menu.md files. See menus file definition for more information on the file format and location.
Dynamic Page Navigation with MDX
When working with documentation or content-heavy pages in Qwik City, you often need to generate a table of contents or sidebar navigation based on the page's content. Qwik City provides a built-in solution for this through the useContent() hook, which can automatically extract headings from your MDX files.
Using useContent() for Page Navigation
The useContent() hook allows you to access metadata about your current MDX page, including all its headings. This is particularly useful for:
- Creating a table of contents for long articles
- Building dynamic sidebar navigation
- Implementing "jump to section" functionality
- Generating progress indicators for article sections
Here's a complete example of how to create a dynamic table of contents:
import { component$, useContent } from '@builder.io/qwik';
export const TableOfContents = component$(() => {
const content = useContent();
return (
<nav class="toc">
<h4>On this page</h4>
<ul>
{content.headings?.map((heading) => (
<li
key={heading.id}
style={{
// Indent based on heading level
marginLeft: `${(heading.level - 1) * 12}px`
}}
>
<a href={`#${heading.id}`}>{heading.text}</a>
</li>
))}
</ul>
</nav>
);
});
Understanding the Headings Data
The headings property from useContent() provides an array of heading objects with the following information:
id: The auto-generated ID for the heading (used for anchor links)text: The actual text content of the headinglevel: The heading level (1 for h1, 2 for h2, etc.)
This only works with .mdx files - headings in .tsx files are not detected.
Common Use Cases
Progressive Disclosure Navigation
You can create a collapsible navigation that shows the current section and its sub-sections:
export const ProgressiveNav = component$(() => {
const content = useContent();
const currentSection = useSignal<string | null>(null);
return (
<nav>
{content.headings?.map((heading) => {
if (heading.level === 2) { // Only show h2 as main sections
const subHeadings = content.headings.filter(h =>
h.level === 3 &&
h.id.startsWith(heading.id.split('-')[0])
);
return (
<div key={heading.id}>
<a
href={`#${heading.id}`}
onClick$={() => currentSection.value = heading.id}
>
{heading.text}
</a>
{currentSection.value === heading.id && (
<ul>
{subHeadings.map(sub => (
<li key={sub.id}>
<a href={`#${sub.id}`}>{sub.text}</a>
</li>
))}
</ul>
)}
</div>
);
}
})}
</nav>
);
});
Reading Progress Indicator
You can combine heading information with scroll position to create a reading progress indicator:
export const ReadingProgress = component$(() => {
const content = useContent();
const activeSection = useSignal('');
useOnWindow('scroll', $(() => {
const headingElements = content.headings?.map(h =>
document.getElementById(h.id)
).filter(Boolean) || [];
const currentHeading = headingElements.find(el => {
const rect = el!.getBoundingClientRect();
return rect.top > 0 && rect.top < window.innerHeight / 2;
});
if (currentHeading) {
activeSection.value = currentHeading.id;
}
}));
return (
<nav>
{content.headings?.map(heading => (
<a
key={heading.id}
href={`#${heading.id}`}
class={{
active: activeSection.value === heading.id
}}
>
{heading.text}
</a>
))}
</nav>
);
});
Tips and Best Practices
-
Consistent Heading Structure: Maintain a logical heading hierarchy in your MDX files to ensure the navigation makes sense.
-
Performance: The
useContent()hook is optimized and won't cause unnecessary re-renders, so you can safely use it in navigation components. -
Styling: Consider using the heading level information to create visual hierarchy in your navigation:
.toc a { /* Base styles */ } /* Style based on heading level */ [data-level="1"] { font-size: 1.2em; font-weight: bold; } [data-level="2"] { font-size: 1.1em; } [data-level="3"] { font-size: 1em; } -
Accessibility: Always ensure your dynamic navigation includes proper ARIA labels and keyboard navigation support.
Notes and Limitations
- This functionality only works with
.mdxfiles, not with.tsxor other file types - Headings must have unique content to generate unique IDs
- The heading data is available only on the client-side after hydration
- Consider using
useVisibleTask$if you need to interact with the heading elements in the DOM