Introduction
Static site generation has been through several waves of maturity. What started as simple HTML pre-rendering has evolved into a nuanced discipline where build-time rendering, partial hydration, island architecture, and rich content authoring all converge. Astro.js represents one of the most thoughtful responses to this evolution - a framework that starts from a "zero JavaScript by default" posture and lets you opt in to interactivity precisely where you need it.
When you pair Astro with React and MDX, you get a powerful authoring pipeline: content authors write in Markdown-flavored MDX with full access to JSX components, engineers define those components once in React, and Astro ensures only the JavaScript that matters reaches the browser. The combination is genuinely compelling for documentation sites, technical blogs, marketing pages, and any content-heavy property where page speed and SEO matter.
But there's a friction point that anyone who has maintained an MDX-heavy codebase knows well: import boilerplate. Every MDX file begins accumulating a header of import statements for every custom component it uses. In a small project this is manageable. In a content repository with hundreds of MDX files - each potentially importing Callout, CodeBlock, Diagram, and TabGroup - the maintenance burden compounds quickly, and content authors who aren't engineers start hitting walls.
This article walks through the full picture: setting up Astro with React and MDX for SSG, understanding how Astro's rendering pipeline processes MDX, and then implementing a component injection layer that makes all your shared React components available globally inside every MDX file without a single import statement. It's a pattern Astro natively supports, but one that's easy to misconfigure if you don't understand the underlying mechanics.
Why Astro for SSG and Content-Heavy Sites
Astro's architecture is deliberately different from Next.js, Gatsby, or Remix in one foundational way: the default output is HTML with zero client-side JavaScript, and hydration is explicit and component-scoped. This model, which Astro calls the Islands Architecture, means a page can have ten components, nine of them purely static, and only one hydrated React island - and the browser only downloads JavaScript for that one island.
For content-heavy sites this matters enormously. A documentation portal where 90% of pages are static prose doesn't need a full React runtime on every page load. Lighthouse scores, Core Web Vitals, and Time to Interactive all benefit directly. Google has made it clear that page experience signals including LCP and CLS influence search ranking, so the architecture choice has SEO consequences, not just performance ones.
Astro also has a first-class understanding of content. Its Content Collections API provides type-safe frontmatter validation using Zod schemas, automated slug generation, and strongly typed queries across your MDX or Markdown files. This isn't an afterthought - it's the primary abstraction for managing large bodies of content, and it integrates with the MDX pipeline naturally.
The framework's multi-framework support is another practical advantage. You can use React components for the interactive islands while keeping Astro components for layouts, navigation, and shells. You don't need to commit to React for everything, and you can migrate incrementally. For teams moving from a Gatsby codebase or a CRA-based documentation site, this reduces the lift considerably.
Project Setup: Astro + React + MDX
Getting a working Astro project with React and MDX configured takes about five minutes and produces a predictable, well-structured scaffold. The official tooling handles the integration packages automatically.
# Create a new Astro project
npm create astro@latest my-blog -- --template blog
cd my-blog
# Add React integration
npx astro add react
# Add MDX integration
npx astro add mdx
Running npx astro add react does three things: installs @astrojs/react and its peer dependencies (react, react-dom), updates astro.config.mjs to register the integration, and adds @types/react if TypeScript is in use. The npx astro add mdx command similarly installs @astrojs/mdx and registers it. After both commands, your astro.config.mjs will look roughly like this:
// astro.config.mjs
import { defineConfig } from "astro/config";
import react from "@astrojs/react";
import mdx from "@astrojs/mdx";
export default defineConfig({
integrations: [react(), mdx()],
output: "static", // default - explicitly stating SSG intent
});
The output: 'static' setting is worth stating explicitly even though it's the default. It signals build intent clearly to anyone reading the config, and it prevents accidental server-mode behavior if someone later installs an adapter for Vercel or Netlify without updating the output mode deliberately.
A canonical project structure for a blog or docs site looks like this:
my-blog/
├── astro.config.mjs
├── src/
│ ├── components/ # React and Astro components
│ │ ├── Callout.tsx
│ │ ├── CodeBlock.tsx
│ │ └── TabGroup.tsx
│ ├── content/
│ │ ├── config.ts # Content Collections schema
│ │ └── blog/
│ │ ├── first-post.mdx
│ │ └── second-post.mdx
│ ├── layouts/
│ │ └── BlogPost.astro
│ └── pages/
│ ├── index.astro
│ └── blog/
│ └── [...slug].astro
└── tsconfig.json
Content lives under src/content/ and is managed via Astro's Content Collections API. Pages under src/pages/ define routes. Layouts wrap page shells. Components - including your React components - live in src/components/. This separation is not enforced by the framework but it's a convention the Astro team promotes and one that scales well.
How MDX Works in Astro
MDX is essentially Markdown with JSX embedded. It's processed at build time: the @astrojs/mdx integration transforms .mdx files into Astro components, which are then rendered to HTML during the static build. From Astro's perspective, an MDX file and an .astro file are both just components - they go through the same rendering pipeline and produce the same output.
This has an important implication: MDX files in Astro can import other Astro or React components directly, just like a regular .astro file can. But they can also receive components through a special mechanism - the components prop - which is how the injection layer works. Understanding this dual-path approach is essential before implementing the global injection layer.
When Astro processes an MDX file, it maps standard Markdown elements like ## headings, > blockquotes, and `code` to HTML elements unless you override them. The components prop is the override mechanism. If you pass a components object to an MDX-rendered component, any key in that object matching an HTML element name or a custom component name gets substituted at render time. For example, passing { h2: MyHeading, pre: SyntaxHighlighter } replaces every ## heading with MyHeading and every fenced code block with SyntaxHighlighter.
// src/pages/blog/[...slug].astro
---
import { getCollection, render } from 'astro:content';
import BlogLayout from '../../layouts/BlogLayout.astro';
import Callout from '../../components/Callout.tsx';
import CodeBlock from '../../components/CodeBlock.tsx';
export async function getStaticPaths() {
const posts = await getCollection('blog');
return posts.map(post => ({
params: { slug: post.slug },
props: { post },
}));
}
const { post } = Astro.props;
const { Content } = await render(post);
---
<BlogLayout frontmatter={post.data}>
<Content components={{ Callout, pre: CodeBlock }} />
</BlogLayout>
In this example, Callout is available inside the MDX file as <Callout /> without any import, because it was injected via the components prop. Fenced code blocks are also replaced by CodeBlock. This is the foundation of the injection pattern - but as shown here, it requires you to import all components in the page-level .astro file. The next section elevates this into a proper reusable layer.
The Component Injection Layer: No Imports in MDX Files
The goal of the injection layer is simple: define your shared MDX components once, in one place, and have them available in every MDX file across your content collection without repeating import statements. Content authors should be able to write <Callout type="warning"> or <TabGroup> in any MDX file and have it just work.
Astro's @astrojs/mdx integration supports this through the components option on the MDX integration itself in astro.config.mjs. This is the cleanest path - you register your global components at the integration level, and Astro automatically passes them to every MDX file rendered through the pipeline.
// astro.config.mjs
import { defineConfig } from "astro/config";
import react from "@astrojs/react";
import mdx from "@astrojs/mdx";
// Import your global MDX components
import Callout from "./src/components/Callout.tsx";
import TabGroup from "./src/components/TabGroup.tsx";
import CodeBlock from "./src/components/CodeBlock.tsx";
import Diagram from "./src/components/Diagram.tsx";
export default defineConfig({
integrations: [
react(),
mdx({
components: {
// Override standard Markdown HTML elements
pre: CodeBlock,
// Register custom named components (no imports needed in MDX)
Callout,
TabGroup,
Diagram,
},
}),
],
output: "static",
});
With this configuration in place, every .mdx file in your project can use <Callout>, <TabGroup>, <Diagram>, and the custom <pre>-based <CodeBlock> without any import statements at the top of the file.
---
title: "Understanding Database Indexing"
publishDate: 2024-11-15
---
## What Is a Database Index?
An index is a data structure that improves the speed of data retrieval at the cost of additional storage and write overhead. Think of it as the index at the back of a textbook - instead of reading every page, you go directly to the reference.
<Callout type="info">
B-tree indexes are the default in most relational databases including
PostgreSQL, MySQL, and SQLite. They support equality and range queries
efficiently.
</Callout>
## When to Use a Composite Index
Composite indexes cover multiple columns. The order of columns matters - the index is most effective when queries filter on the leftmost columns first.
<TabGroup tabs={["PostgreSQL", "MySQL", "SQLite"]}>
<div slot="PostgreSQL">
`sql CREATE INDEX idx_user_created ON orders(user_id, created_at DESC)`
</div>
...
</TabGroup>
Notice there are zero import statements. The MDX content reads as clean prose with embedded components, exactly as a technical author would want to write it.
Building the Injection Layer in Practice
The configuration-level injection approach works well for smaller component sets, but as your component library grows you'll want to manage the injection list as a dedicated module rather than inline in astro.config.mjs. This also makes the components more discoverable and testable as a unit.
A practical pattern is to create an mdx-components.ts file that exports the components map, then import it in the Astro config:
// src/mdx-components.ts
import type { MDXComponents } from "@astrojs/mdx";
import Callout from "./components/Callout.tsx";
import TabGroup from "./components/TabGroup.tsx";
import CodeBlock from "./components/CodeBlock.tsx";
import Diagram from "./components/Diagram.tsx";
import InlineCode from "./components/InlineCode.tsx";
import ImageFigure from "./components/ImageFigure.tsx";
/**
* Global MDX component injection map.
* Components registered here are available in every .mdx file
* without explicit imports.
*
* Keys matching HTML element names override the default rendering.
* Custom keys become available as named JSX components.
*/
export const mdxComponents: MDXComponents = {
// HTML element overrides
pre: CodeBlock,
code: InlineCode,
img: ImageFigure,
// Custom named components
Callout,
TabGroup,
Diagram,
};
// astro.config.mjs
import { defineConfig } from "astro/config";
import react from "@astrojs/react";
import mdx from "@astrojs/mdx";
import { mdxComponents } from "./src/mdx-components.ts";
export default defineConfig({
integrations: [
react(),
mdx({
components: mdxComponents,
}),
],
output: "static",
});
Now let's look at what a realistic React component looks like inside this system. A Callout component is a common primitive in technical documentation - it highlights notes, warnings, and tips in a visually distinct way:
// src/components/Callout.tsx
import React from 'react';
type CalloutType = 'info' | 'warning' | 'danger' | 'tip';
interface CalloutProps {
type?: CalloutType;
title?: string;
children: React.ReactNode;
}
const typeConfig: Record<CalloutType, { label: string; className: string }> = {
info: { label: 'Note', className: 'callout-info' },
warning: { label: 'Warning', className: 'callout-warning' },
danger: { label: 'Danger', className: 'callout-danger' },
tip: { label: 'Tip', className: 'callout-tip' },
};
export const Callout: React.FC<CalloutProps> = ({
type = 'info',
title,
children,
}) => {
const config = typeConfig[type];
return (
<aside className={`callout ${config.className}`} role="note">
<strong className="callout-label">
{title ?? config.label}
</strong>
<div className="callout-body">{children}</div>
</aside>
);
};
export default Callout;
For the CodeBlock component - the one replacing the <pre> element - you'll want to handle the children carefully. Astro passes the raw code string and language metadata as props, derived from the fenced code block syntax in MDX:
// src/components/CodeBlock.tsx
import React from 'react';
interface CodeBlockProps {
children?: React.ReactNode;
className?: string;
'data-language'?: string;
}
/**
* Replaces the default <pre> rendering in MDX.
* Astro/MDX passes the language via the className ('language-ts')
* and optionally via data-language.
*/
export const CodeBlock: React.FC<CodeBlockProps> = ({
children,
className = '',
'data-language': dataLanguage,
}) => {
const language =
dataLanguage ??
className.replace('language-', '') ??
'text';
return (
<div className="code-block-wrapper" data-language={language}>
<span className="code-block-lang-label">{language}</span>
<pre className={className}>
{children}
</pre>
</div>
);
};
export default CodeBlock;
One important consideration for components that contain interactive React: Astro renders everything to static HTML by default, even React components. If Callout is purely presentational, this is fine - it renders at build time and ships as HTML. If TabGroup requires client-side state to switch tabs, you need to hydrate it. In the injection layer context, you can't use the client:load directive directly in MDX on auto-injected components, so you have two options.
The first option is to create a hydrated wrapper component:
// src/components/TabGroupIsland.tsx
// This is the version used in MDX - it will be hydrated
import React, { useState } from 'react';
interface TabGroupProps {
tabs: string[];
children: React.ReactNode[];
}
export const TabGroup: React.FC<TabGroupProps> = ({ tabs, children }) => {
const [activeTab, setActiveTab] = useState(0);
return (
<div className="tab-group">
<div className="tab-list" role="tablist">
{tabs.map((tab, i) => (
<button
key={tab}
role="tab"
aria-selected={i === activeTab}
onClick={() => setActiveTab(i)}
className={i === activeTab ? 'tab-active' : 'tab'}
>
{tab}
</button>
))}
</div>
<div className="tab-panel" role="tabpanel">
{React.Children.toArray(children)[activeTab]}
</div>
</div>
);
};
export default TabGroup;
The second option is to wrap the injected component in an Astro island at the page level. Since the [...slug].astro page controls rendering, you can wrap the <Content /> component call inside an island boundary using client:load on the interactive component, and handle the hydration at that layer. For most documentation use cases, the first option - building self-contained React components that handle their own state - is simpler and more composable.
Trade-offs and Pitfalls
The injection layer pattern is powerful but it comes with real trade-offs that are worth understanding before you adopt it at scale.
Build-time configuration coupling. By moving component registration into astro.config.mjs (or a module imported by it), you're tying your component library to your build configuration. This means adding a new global MDX component requires touching the config and re-running the build - there's no runtime or hot-module-replacement path for new entries in the injection map. For active content sites this is usually acceptable, but it's different from the dynamic import model that some other MDX setups use.
TypeScript discoverability gaps. When components are injected globally, your MDX files gain access to component names that aren't imported anywhere in the file. Most TypeScript language servers and MDX IDE plugins handle this through a global type declaration, but the ergonomics are imperfect. If you rename a component in mdx-components.ts and forget to update the usages in MDX files, the TypeScript compiler won't catch it unless you've set up explicit MDX type checking. The Astro team provides @astrojs/check for this, and it's worth integrating into your CI pipeline.
Prop type errors are silent in MDX. Inside a .tsx file, passing the wrong prop type to a React component is a type error. Inside an .mdx file, the same mistake may not surface as clearly, depending on your IDE setup. This is a general MDX limitation, not specific to the injection layer, but it compounds in a large content repository where many authors are writing MDX without deep TypeScript familiarity.
Tree-shaking behavior. Because all globally injected components are imported into astro.config.mjs at the module level, they're all included in the build graph regardless of whether any MDX file actually uses them. In practice, Astro's build pipeline handles this reasonably well for static output, but it's worth being deliberate about which components you register globally versus leaving as explicit per-file imports.
Hydration awareness. As noted in the previous section, injected React components render statically by default. If a content author writes <TabGroup> expecting it to have tab-switching behavior, and the component hasn't been configured for client-side hydration, they'll get a non-functional UI with no obvious error. Establishing a clear convention about which injected components are interactive and ensuring they're built as self-hydrating islands (using React state internally) prevents this class of bug.
Best Practices
Adopt a naming convention that distinguishes interactive components from static ones. A common approach is a suffix like Island for components that own client-side state: Callout is static, TabGroupIsland is interactive. This makes the hydration behavior visible in the component name and helps content authors understand what they're using.
Keep the global injection map small and intentional. Not every component in your design system needs to be in the global map. A good rule of thumb: if a component is used in fewer than 30% of your MDX files, it's better as an explicit import. The global map should contain primitives that appear almost everywhere - callouts, code blocks, inline code, images - and leave domain-specific components as explicit imports. This keeps the cognitive overhead of "what's available globally" manageable.
Version your component API carefully. Because injected components are used across many MDX files simultaneously, a breaking change to a prop interface - renaming type to variant on Callout, for example - breaks every MDX file that uses that prop. Treat globally injected components as a public API and apply deprecation cycles before removing props.
Invest in @astrojs/check in CI. Running astro check as part of your CI pipeline catches type errors in both .astro and .mdx files. This is especially important for MDX files where the language server feedback during authoring may be weaker than in TypeScript source files. A failing CI check on a bad prop usage is much better than discovering it in production.
Write a developer-facing reference for content authors. A simple COMPONENTS.md or a Storybook-style page listing every globally available MDX component with its props and usage examples dramatically reduces the support burden on the engineering team. Content authors shouldn't have to read source code to understand what <Callout type="warning"> does.
Collocate component styles with components. Each React component should own its own CSS module or styled definition rather than relying on global stylesheet additions. This makes components portable, prevents naming collisions, and ensures that adding a new component to the injection map doesn't require a parallel stylesheet change.
Key Takeaways
These are the five things you can act on immediately after reading this article:
-
Run
npx astro add react mdxand setoutput: 'static'explicitly inastro.config.mjs. This gives you a well-typed, SSG-first foundation in under ten minutes. -
Create
src/mdx-components.tsas the single source of truth for your global MDX component map. Import it intoastro.config.mjsvia themdx({ components: mdxComponents })option. All registered components become available in every.mdxfile without imports. -
Override HTML element renderers - particularly
preandcode- in the component map. This is where you plug in syntax highlighting, custom code block UI, and copyable code examples without touching any MDX content files. -
Build interactive components as self-hydrating React islands using
useStateanduseEffectinternally, rather than relying on Astro'sclient:*directives inside MDX. This avoids the hydration directive limitation on injected components and makes the components reusable across both MDX and.astrofiles. -
Run
astro checkin CI to catch prop type errors and broken MDX references before they reach production.
80/20 Insight
The vast majority of the value in this entire setup comes from two things: the components option on the MDX integration, and the decision to keep your global component map small and well-typed. Everything else - the project scaffold, the React component architecture, the styling conventions - is standard engineering discipline that applies everywhere. If you understand how the components prop works at the MDX pipeline level, you can debug any problem, adapt the pattern to different project structures, and explain it to a colleague. The rest is configuration.
Analogies and Mental Models
Think of the component injection layer as a global CSS stylesheet for React components. Just as a stylesheet imported in a layout file applies styles to every page without each page importing the stylesheet, the MDX component map applies component substitutions to every MDX file without each file importing the components. The analogy holds for the tradeoffs too: global styles can cause unintended side effects, and so can a poorly designed global component map. Both reward deliberate minimalism.
Another useful frame is dependency injection in application code. The MDX components prop is essentially an inversion of control mechanism - instead of the MDX consumer (the content file) pulling in its dependencies, the dependencies are pushed in from the outside by the rendering layer. This is the same pattern you'd use with a DI container in a backend service: it reduces coupling, centralizes configuration, and makes components easier to swap out.
Conclusion
Astro's combination of island architecture, first-class MDX support, and the components injection API gives you a genuinely well-designed stack for content-heavy static sites. The zero-JavaScript default is not a constraint - it's a discipline enforcer that pushes you to be intentional about what deserves client-side behavior and what doesn't.
The component injection layer is the most immediately practical pattern in this stack. It pays for itself quickly in any project with more than a handful of MDX files and more than two or three authors. Content writers shouldn't need to understand JavaScript module resolution to add a callout box to a blog post. The injection layer solves that problem cleanly, at the framework integration level, with type safety and no runtime overhead.
Getting the pattern right requires understanding three things: how Astro's MDX integration processes content files at build time, how the components prop flows into the rendered output, and where the hydration boundary sits for interactive components. This article has walked through all three. From here, the logical next steps are exploring Astro's Content Collections API for type-safe frontmatter, integrating a syntax highlighting library like Shiki (which Astro ships by default) into the CodeBlock component, and thinking carefully about which parts of your site actually need React versus which can be purely Astro components rendered to zero-JS HTML.
References
- Astro Documentation - MDX Integration https://docs.astro.build/en/guides/integrations-guide/mdx/
- Astro Documentation - React Integration https://docs.astro.build/en/guides/integrations-guide/react/
- Astro Documentation - Content Collections https://docs.astro.build/en/guides/content-collections/
- Astro Documentation - Islands Architecture https://docs.astro.build/en/concepts/islands/
- Astro Documentation - Static Site Generation (SSG) https://docs.astro.build/en/basics/rendering-modes/#pre-rendered
- MDX Documentation - Using Components https://mdxjs.com/docs/using-mdx/#components
- MDX Documentation - Providing Components https://mdxjs.com/docs/using-mdx/#providing-components
- @astrojs/mdx - npm package https://www.npmjs.com/package/@astrojs/mdx
- Astro -
astro checkCLI reference https://docs.astro.build/en/reference/cli-reference/#astro-check - Google - Core Web Vitals documentation https://web.dev/articles/vitals
- Jason Miller - "Islands Architecture" (original concept post) https://jasonformat.com/islands-architecture/