CoursesMarkdown Routes with Next.jsThe CopyMarkdown Component
Markdown Routes with Next.js

The CopyMarkdown Component

AI agents fetch markdown automatically, but humans need a button.
Log in to mark your progress for each Lesson and Task
  • Build a React component for copying markdown to clipboard
  • Fetch the markdown URL and handle the response
  • Provide visual feedback for loading, success, and error states
  • Use progressive enhancement so the link still works without JavaScript

Your markdown routes are great for AI agents that know to set the Accept header. But human users don't know this exists — they need a discoverable UI element.

A "Copy Markdown" button:

  1. Teaches users the feature exists
  2. Provides quick access without leaving the page
  3. Falls back gracefully if clipboard access or the network fails

Here's the complete CopyMarkdown component:

"use client";
import { useState } from "react";
type CopyState = "idle" | "loading" | "copied" | "error";
export function CopyMarkdown({
path,
label = "Copy Markdown",
}: {
path: string;
label?: string;
}) {
const [state, setState] = useState<CopyState>("idle");
const markdownUrl = `${path}.md`;
const handleCopy = async (e: React.MouseEvent<HTMLAnchorElement>) => {
e.preventDefault();
setState("loading");
try {
const response = await fetch(markdownUrl);
if (!response.ok) throw new Error("Failed to fetch markdown");
const markdown = await response.text();
await navigator.clipboard.writeText(markdown);
setState("copied");
setTimeout(() => setState("idle"), 2000);
} catch (error) {
console.error("Copy failed:", error);
setState("error");
window.open(markdownUrl, "_blank");
setTimeout(() => setState("idle"), 2000);
}
};
const stateConfig = {
idle: { text: label, className: "text-gray-600 hover:text-gray-900" },
loading: { text: "Loading...", className: "text-gray-600" },
copied: {
text: "Copied!",
className: "text-green-600 bg-green-50 hover:bg-green-100",
},
error: {
text: "Error (opened in tab)",
className: "text-red-600 bg-red-50 hover:bg-red-100",
},
};
const { text, className } = stateConfig[state];
return (
<a
href={markdownUrl}
onClick={handleCopy}
className={`inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${className}`}
target="_blank"
rel="noopener noreferrer"
>
{text}
</a>
);
}

Add the component to your docs article page:

import { CopyMarkdown } from "@/components/CopyMarkdown";
export default function ArticlePage({ params }: { params: { slug: string[] } }) {
const path = `/docs/${params.slug.join("/")}`;
return (
<article>
<header className="flex items-center justify-between">
<h1>Article Title</h1>
<CopyMarkdown path={path} />
</header>
{/* Article content */}
</article>
);
}

The component is built as progressive enhancement:

  1. The anchor's href points directly to the .md URL, so it works as a normal link
  2. JavaScript enhances the link with onClick to intercept navigation and copy to clipboard
  3. If JavaScript is disabled, the browser follows the link and opens the markdown in a new tab
  4. If the clipboard write or fetch fails, the component falls back to window.open(markdownUrl, '_blank')

Example fallback behavior:

catch (error) {
console.error("Copy failed:", error);
setState("error");
// Fallback: open in new tab
window.open(markdownUrl, "_blank");
setTimeout(() => setState("idle"), 2000);
}

The component cycles through four states:

  • idle — Shows "Copy Markdown" in neutral gray
  • loading — Shows "Loading..." while fetching markdown
  • copied — Shows "Copied!" with green background when successful
  • error — Shows "Error (opened in tab)" with red background when copy fails

The copied and error states automatically reset to idle after 2 seconds.

If you're using Sanity's Visual Editing with Stega encoding, you may need to clean the markers from the markdown before copying:

import { stegaClean } from "@sanity/client/stega";
const markdown = await response.text();
const cleanMarkdown = stegaClean(markdown);
await navigator.clipboard.writeText(cleanMarkdown);

This ensures users get clean markdown without invisible encoding markers.

The component uses Tailwind classes for styling. The design decisions:

  • Neutral gray for idle and loading states (non-intrusive)
  • Green background for success (clear positive feedback)
  • Red background for errors (clear negative feedback)
  • Rounded corners and padding for a button-like appearance
  • Smooth transitions between states

Adjust the styling to match your design system.

Test the component:

  • Click the button and verify markdown is copied to clipboard
  • Check that the "Copied!" state appears briefly
  • Paste the clipboard content to verify it's clean markdown

Test the fallback behavior:

  • Disable JavaScript in your browser and verify the link still opens the markdown
  • Test with an invalid path to verify the error state and fallback

For production, consider:

  • Adding analytics to track how often users copy markdown
  • Customizing the button label per page or section
  • Adding keyboard shortcuts for power users
  • Implementing rate limiting if you're concerned about abuse
Mark lesson as complete
You have 1 uncompleted task in this lesson
0 of 1