Tutorials Astro & Next.js Series Chapter 4

MDX, Astro Islands vs Next.js Server Components, and Deploying Both on Cloudflare

Astro + Next.jsChapter 4 of the Astro & Next.js Series26 minMay 31, 2026Beginner

Chapter 3 made the routes correct. Now we render content into them. Two pieces left:

  1. 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.
  2. 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 mdx

That 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:

Loading diagram…

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:

// 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

NeedAstroNext.js
Pure static articleDefault — no directive neededDefault Server Component
One small interactive widget mid-page<Widget client:visible />"use client" in the widget
Form that writes to DBPOST to a Worker routeServer Action (above)
Heavy dashboard with stateBig island, or skip AstroClient Components throughout
Real-time updatesWorker + WebSocketServer Component + Suspense streaming

Deploying Astro on Cloudflare

npx astro add cloudflare

Installs @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:

Build and deploy:

npm run build              # produces dist/ + a Worker bundle
wrangler deploy            # ships to your Workers account

Deploying 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 deploy

That'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 coversYesYes, 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

  1. 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.
  2. 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.
  3. 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)

  1. Add MDX to an Astro project (npx astro add mdx). Create src/content/articles/hello.mdx with frontmatter and a paragraph. Render it from src/pages/articles/[slug].astro.
  2. In an Astro page, render a static <Header /> and a <Counter client:visible />. View the page in DevTools Network tab — confirm the Counter JS only loads when you scroll to it.
  3. In a Next.js Server Component, await fetch("https://api.example.com/data") directly. Confirm there's no client-side fetch and the data is in the initial HTML.
  4. Add a "use server" action that writes a row to D1 / a local file. Call it from a form's action prop. No API route written.
  5. Deploy both. Use @astrojs/cloudflare for the Astro one (set output: 'static' if you can). Use OpenNext for the Next.js one. Visit both at their *.workers.dev URLs.

Where This Lands in the Series

That's the Astro & Next.js Series, complete:

  1. Why a Framework? Astro vs Next.js — the decision.
  2. File-Based Routing + What [slug] Means — how files become URLs and what the brackets do.
  3. Canonical URLs, Route Params & SEO — making routes Google-friendly.
  4. 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."

Ch 3: Canonical URLs, Route Params & SEOComing Soon →
WebUltimate Web Development SeriesWeb development tutorials for HTML, CSS, JavaScript, Next.js, Workers, databases, and production shipping.Production WebProduction Web Apps SeriesProduction patterns for web apps: caching, rate limiting, webhooks, queues, cron jobs, and idempotency.CloudflareCloudflare Feature FocusFocused Cloudflare tutorials for Workers, R2, Stream, Durable Objects, and edge deployment.

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
5 free articles remainingSubscribe for unlimited access