Chapter 3 made the routes correct. Now we render content into them. Two pieces left:
- MDX — Markdown with embedded JSX components. The format every modern docs site uses (including this one), and the natural pairing with file-based dynamic routing.
- The rendering models — Astro Islands vs Next.js Server Components vs Client Components vs Server Actions. Picking the right model is what makes pages fast.
Then we deploy both — to Cloudflare Workers, the same edge runtime that hosts this site — and look at the bill.
MDX: Markdown + JSX
MDX is Markdown with one superpower: you can drop JSX components anywhere in the document. The same file can be:
---
title: "RDAP Explained"
chapter: 1
---
# {frontmatter.title}
Most of the time it looks like a simple line:
<Callout variant="warn" title="ICANN sunset WHOIS in Jan 2025.">
WHOIS for gTLDs is no longer a contractual requirement. Use RDAP.
</Callout>
Try it now:
```bash
curl https://rdap.org/domain/example.com
Everything outside the JSX tags is regular Markdown (headings, lists, code fences, links). Everything inside a JSX tag is a real React/Astro component you imported. You get the best of both worlds: text reads like prose, components do interactive work.
**This is exactly how this site's chapters are written.** The file you're reading right now is `src/content/astro-and-nextjs/mdx-rendering-and-cloudflare-deploy.mdx`. The `<Callout>` blocks, the tables, the mermaid diagrams — all components or special blocks that MDX renders.
### MDX in Astro
```bash
npx astro add mdxThat installs @astrojs/mdx and adds it to astro.config.mjs. After it runs, any .mdx file in src/pages/ becomes a route automatically; any .mdx in src/content/ is loadable as a Content Collection.
---
// src/pages/articles/[slug].astro
import { getEntry, getCollection } from 'astro:content';
export async function getStaticPaths() {
const articles = await getCollection('articles');
return articles.map(a => ({ params: { slug: a.slug }, props: { article: a } }));
}
const { article } = Astro.props;
const { Content } = await article.render();
---
<h1>{article.data.title}</h1>
<Content />article.render() returns a component you mount with <Content />. Astro's MDX support is first-class — frontmatter, components, syntax highlighting all included.
MDX in Next.js
Two routes here. The official @next/mdx route auto-routes .mdx files in pages/ or app/:
npm install @next/mdx @mdx-js/loader @mdx-js/react @types/mdx// next.config.mjs
import nextMDX from "@next/mdx";
const withMDX = nextMDX();
export default withMDX({ pageExtensions: ["ts", "tsx", "js", "jsx", "md", "mdx"] });Or — what this site does — use next-mdx-remote to load .mdx from a separate content directory at request/build time:
// app/articles/[slug]/page.tsx
import { compileMDX } from "next-mdx-remote/rsc";
import fs from "fs";
export default async function Page({ params }) {
const { slug } = await params;
const raw = fs.readFileSync(`src/content/articles/${slug}.mdx`, "utf-8");
const { content, frontmatter } = await compileMDX({ source: raw, options: { parseFrontmatter: true } });
return (
<article>
<h1>{frontmatter.title}</h1>
{content}
</article>
);
}That's the pattern used to render every chapter on this site. The benefits: content lives in plain .mdx files (easy to author, lints with Markdown tools), the route renders them with React Server Components at build/request time, and MDX components are typed and reusable.
The Four Rendering Models
The biggest practical difference between Astro and Next.js is which component runs where. Four models cover almost every case:
Figure 1 — Left to right: zero JS → maximum JS. The whole performance art is keeping each component as far left as possible while still being functional.
Astro Islands
Astro renders every page to static HTML by default. Components that need interactivity are explicitly marked as islands using a client:* directive:
---
import Counter from '../components/Counter.jsx';
import Header from '../components/Header.astro';
---
<Header /> <!-- Pure HTML, no JS -->
<Counter client:load /> <!-- Hydrates immediately -->
<Counter client:visible /> <!-- Hydrates when scrolled into view -->
<Counter client:idle /> <!-- Hydrates when browser is idle -->
<Counter client:media="(max-width: 600px)" /> <!-- Only on mobile -->
<Counter client:only="react" /> <!-- No server render at all — client-only -->The client:visible directive is the killer move: an interactive widget at the bottom of the page only loads its JS when the user actually scrolls there. Until then, zero JS for that component.
Astro 5 added Server Islands — components that render on the server per request (not at build time), letting you mix static and dynamic content on the same page without losing the build-time-static base.
Next.js Server Components
Next.js (App Router) makes the opposite default: every component is a Server Component unless marked otherwise. Server components:
- Run on the server, return HTML.
- Can be async —
awaitdata inside them directly. - Don't ship their JS to the browser.
- Can't use
useState,useEffect, or browser APIs.
// app/articles/[slug]/page.tsx — Server Component (no 'use client')
export default async function Page({ params }) {
const { slug } = await params;
const article = await db.articles.findUnique({ where: { slug } }); // ← runs on server
return <h1>{article.title}</h1>;
}When you need interactivity, you mark a component as Client Component with "use client" at the top:
// app/components/Counter.tsx
"use client";
import { useState } from "react";
export default function Counter() {
const [n, setN] = useState(0);
return <button onClick={() => setN(n + 1)}>{n}</button>;
}Now this component (and its JS bundle) ships to the browser. Server components can import client components freely; client components can't import server components.
Server Actions
Next.js's killer feature for forms: a function with "use server" at the top is callable directly from a Client Component, but runs on the server. No API route needed:
"use client";
import { addComment } from "../actions"; // server-only function
export function CommentForm({ postId }) {
return (
<form action={async (formData) => addComment(postId, formData.get("body"))}>
<textarea name="body" />
<button>Post</button>
</form>
);
}// app/actions.ts
"use server";
export async function addComment(postId: string, body: string) {
await db.comments.create({ data: { postId, body } });
}That's a form that writes to the database — without a single API route, fetch call, or JSON serialization. Server Actions are why Next.js feels productive for app-shaped sites.
When to Use Which
| Need | Astro | Next.js |
|---|---|---|
| Pure static article | Default — no directive needed | Default Server Component |
| One small interactive widget mid-page | <Widget client:visible /> | "use client" in the widget |
| Form that writes to DB | POST to a Worker route | Server Action (above) |
| Heavy dashboard with state | Big island, or skip Astro | Client Components throughout |
| Real-time updates | Worker + WebSocket | Server Component + Suspense streaming |
Deploying Astro on Cloudflare
npx astro add cloudflareInstalls @astrojs/cloudflare and configures astro.config.mjs:
import { defineConfig } from 'astro/config';
import cloudflare from '@astrojs/cloudflare';
export default defineConfig({
site: 'https://example.com',
output: 'server', // or 'static' for fully-static
adapter: cloudflare(),
});The output setting decides:
'static'— everything pre-renders to HTML at build time. Cheapest, fastest, no Worker request per page.'server'— every request hits a Worker. Required for Server Islands, server-side data fetching per request.'hybrid'— static by default, with explicitexport const prerender = falseon routes that need server rendering.
Build and deploy:
npm run build # produces dist/ + a Worker bundle
wrangler deploy # ships to your Workers accountDeploying Next.js on Cloudflare
Next.js's official runtime is Vercel; Cloudflare uses an adapter called OpenNext:
npm install -D @opennextjs/cloudflare// open-next.config.ts
import { defineCloudflareConfig } from "@opennextjs/cloudflare";
export default defineCloudflareConfig({});# wrangler.toml
name = "my-next-site"
main = ".open-next/worker.js"
compatibility_date = "2026-05-30"
[assets]
directory = ".open-next/assets"npm run cf:build # = npx opennextjs-cloudflare build
npm run cf:deploy # = npx opennextjs-cloudflare deployThat's how this very site deploys. The Worker handles dynamic routes, the assets are served from R2/Workers Assets, and the whole thing runs on the same Workers free tier you'd use for anything else.
The Bill at Indie Scale
| Astro (static + light server) | Next.js (OpenNext) | |
|---|---|---|
| Workers requests/day for ~100k visits | ~100k (1 per visit) | ~100k–300k (page + API + RSC requests) |
| Free tier covers | Yes | Yes, comfortably |
| Paid plan kicks in at | $5/mo above 100k req/day | $5/mo above 100k req/day |
| Cold start | ~0 ms (mostly cached HTML) | ~5 ms (Worker isolate) |
| Total at indie scale | $0–5/mo | $0–5/mo |
For the full Cloudflare-bill story (R2, D1, KV, Stream) see Cloudflare Course Platform Ch 1. The framework you pick affects bundle size and CDN cache behaviour more than it affects the bill.
Mental Model — Three Sentences
- MDX is Markdown with JSX — write articles in plain prose and embed real components (Callouts, charts, code snippets) anywhere — and both frameworks support it natively as the standard content format.
- Astro's default is zero-JS HTML; opt into JS per component with
client:*directives. Next.js's default is React Server Components; opt into client JS with"use client". Two opposite defaults, same goal: ship less JavaScript. - Both frameworks deploy to Cloudflare Workers — Astro via
@astrojs/cloudflare(output: 'server'or'static'), Next.js via@opennextjs/cloudflare— and the bill at indie scale is the same $0–5/mo either way.
Try It Yourself (15 Minutes)
- Add MDX to an Astro project (
npx astro add mdx). Createsrc/content/articles/hello.mdxwith frontmatter and a paragraph. Render it fromsrc/pages/articles/[slug].astro. - In an Astro page, render a static
<Header />and a<Counter client:visible />. View the page in DevTools Network tab — confirm theCounterJS only loads when you scroll to it. - In a Next.js Server Component,
await fetch("https://api.example.com/data")directly. Confirm there's no client-sidefetchand the data is in the initial HTML. - Add a
"use server"action that writes a row to D1 / a local file. Call it from a form'sactionprop. No API route written. - Deploy both. Use
@astrojs/cloudflarefor the Astro one (setoutput: 'static'if you can). Use OpenNext for the Next.js one. Visit both at their*.workers.devURLs.
Where This Lands in the Series
That's the Astro & Next.js Series, complete:
- Why a Framework? Astro vs Next.js — the decision.
- File-Based Routing + What
[slug]Means — how files become URLs and what the brackets do. - Canonical URLs, Route Params & SEO — making routes Google-friendly.
- MDX + Rendering Models + Cloudflare Deploy — content + rendering + shipping (this chapter).
You can now pick a framework with confidence, design URLs that pre-render and rank, write content in MDX, and deploy the whole thing onto Cloudflare for under $5/month. Combined with the Production Web Apps series (caching, rate limiting, webhooks, queues) and the Cloudflare stack chapters, you have the full modern-web playbook on this site — from "I want to build a site" to "I have a site that ships, ranks, and scales."
Ship your apps faster
When you're ready to publish your Swift app to the App Store, Simple App Shipper handles metadata, screenshots, TestFlight, and submissions — all in one place.
Try Simple App Shipper