Handling Markdown Documents in Gatsby
Markdown files serve as the backbone for many Gatsby sites, especially blogs and documentation portals. The framework's GraphQL data layer can ingest these files, transform their content, and expose it to React components before the build process compiles everything into static HTML. But working with Markdown in Gatsby isn't always straightforward, particularly when you need to render content safely or handle embedded media.
Setting up Markdown support begins with the same gatsby-source-filesystem plugin used to query other local assets. Registration in gatsby-config.js tells Gatsby which directory contains your Markdown files:
module.exports = {
plugins: [
{
resolve: `gatsby-source-filesystem`,
options: {
name: `assets`,
path: `${ __dirname }/src/assets`,
},
},
],
};
With a sample Markdown file present in ./src/assets, the structure includes two distinct parts: the frontmatter and the body. The frontmatter, wrapped in triple dashes (---), holds metadata like title and date that Gatsby can use as query arguments. The body contains the actual content.
To make these files available through GraphQL, add the gatsby-transformer-remark plugin to your configuration:
module.exports = {
plugins: [
{
resolve: `gatsby-transformer-remark`,
options: { },
},
],
};
After restarting the development server, the GraphQL playground at http://localhost:8000/___graphql lets you query the file by its frontmatter properties, such as title:
query {
markdownRemark(frontmatter: { title: { eq: "sample-markdown-file" } }) {
html
}
}
The query response returns the Markdown body converted to HTML. You can also access the original format as rawMarkdownBody if you need the unprocessed content for your use case.
Injecting HTML with dangerouslySetInnerHTML
React's dangerouslySetInnerHTML prop is a direct way to inject the HTML produced from your Markdown files into the DOM. However, it bypasses React's sanitization mechanisms, which means you need to be careful about cross-site scripting (XSS) vulnerabilities. Libraries like dompurify can sanitize input before rendering if you choose this path.
The prop expects an object with an __html key containing the raw HTML. In a Gatsby component, you can retrieve the HTML string using useStaticQuery and pass it along:
import * as React from "react";
import { useStaticQuery, graphql } from "gatsby";
const DangerouslySetInnerHTML = () => {
const data = useStaticQuery(graphql`
query {
markdownRemark(frontmatter: { title: { eq: "sample-markdown-file" } }) {
html
}
}
`);
return <div></div>;
};
Visiting the rendered page reveals a problem: images referenced in the Markdown body don't appear because Gatsby hasn't been told to process them. Two workarounds exist:
- Process images with
gatsby-remark-images. This plugin parses Markdown images and makes them available in GraphQL queries, but you'll need to handle the resulting HTML with something likerehype-reactto render it as React components. It also requires additional configuration. - Store images in the
staticfolder. Assets placed there bypass webpack processing but become available in thepublicdirectory, so you can reference them in Markdown using a simple path. The tradeoff is losing Gatsby's built-in image optimization and compression features.
The gatsby-remark-images route is better suited for larger projects that need structured handling and optimization, though the static folder approach works well for quick implementation:
const StaticImage = () => {
return <img src={ "/desert.png" } alt="Desert" />;
};
react-markdown for Safer Rendering
If you want to avoid the security concerns of dangerouslySetInnerHTML, the react-markdown component offers a safer parser. It builds a virtual DOM from a syntax tree, meaning only changed portions update rather than a complete DOM replacement. The package integrates with remark's plugin system, giving you access to a wide ecosystem of extensions.
After installing the package:
npm i react-markdown
You can replace the HTML query with rawMarkdownBody and pass the result to the component for rendering, eliminating the need for manual HTML injection:
import * as React from "react";
import ReactMarkdown from "react-markdown";
import { useStaticQuery, graphql } from "gatsby";
const MarkdownReact = () => {
const data = useStaticQuery(graphql`
query {
markdownRemark(frontmatter: { title: { eq: "sample-markdown-file" } }) {
rawMarkdownBody
}
}
`);
return <ReactMarkdown>{data.markdownRemark.rawMarkdownBody}</ReactMarkdown>;
};
A Lightweight Alternative: markdown-to-jsx
For performance-focused projects, markdown-to-jsx is the most popular Markdown rendering component and ships with zero dependencies. It operates similarly to react-markdown but doesn't require the remark plugin ecosystem. Simply import the Markdown component and feed it the raw Markdown string to convert it to JSX:
npm i markdown-to-jsx
Markdown Editing with react-md-editor
There are cases where you don't want to render Markdown at all. If you're building a lightweight CMS with editing capabilities, you need users to see the raw source. The react-md-editor package provides both an editor and a preview component in one solution.
Install the dependency:
npm i @uiw/react-md-editor
Set up the MDEditor component as a controlled component to manage the text content:
import * as React from "react";
import { useState } from "react";
import MDEditor from "@uiw/react-md-editor";
const ReactMDEditor = () => {
const [value, setValue] = useState("**Hello world!!!**");
return <MDEditor value={ value } onChange={ setValue } />;
};
The package includes a built-in MDEditor.Markdown component for rendering a live preview of the content as users type:
import * as React from "react";
import { useState } from "react";
import MDEditor from "@uiw/react-md-editor";
const ReactMDEditor = () => {
const [value, setValue] = useState("**Hello world!**");
return (
<>
<MDEditor value={value} onChange={ setValue } />
<MDEditor.Markdown source={ value } />
</>
);
};
PDF Embedding In Gatsby: Four Ways
PDFs differ from Markdown in a fundamental way: whereas Markdown stores content in its rawest form, a PDF is the presented content. Users expect to view or download a PDF directly, not consume it as parsed text. Here are four approaches to embedding PDFs in a Gatsby page, ranging from the simplest to the most feature-complete.
The <iframe> Shortcut
An iframe pointing directly at the PDF is the quickest route — no packages required:
import * as React from "react";
import samplePDF from "./assets/lorem-ipsum.pdf";
const IframePDF = () => {
return <iframe src={ samplePDF }></iframe>;
};
The element supports lazy loading via loading="lazy", so off-screen PDFs don't block initial page render.
Third-Party Viewers Inside An iframe
If your PDFs live on a service like Google Drive, you can embed that provider's built-in viewer by pointing the same iframe at it:
import * as React from "react";
const ThirdPartyIframePDF = () => {
return (
<iframe
src="https://drive.google.com/file/d/1IiRZOGib_0cZQY9RWEDslMksRykEnrmC/preview"
allowFullScreen
title="PDF Sample in Drive"
/>
);
};
The security caveat here is critical: you have no control over third-party content. If the source document becomes compromised, your page inherits that risk. Avoid embedding untrusted sources you don't control.
Rendering With react-pdf
The react-pdf package renders PDFs as React components on an HTML <canvas>, powered by Mozilla's pdf.js parser. It exposes two core components:
Document— loads the PDF passed to itsfileprop;Page— renders a single page given bypageNumber, nested insideDocument.
Install it alongside pdfjs-dist:
npm i react-pdf
Before use, register a service worker so pdf.js can offload time-consuming work like parsing and rendering:
import * as React from "react";
import { pdfjs } from "react-pdf";
pdfjs.GlobalWorkerOptions.workerSrc = "https://unpkg.com/[email protected]/build/pdf.worker.min.js";
const ReactPDF = () => {
return <div></div>;
};
Then import the components, their styles, and pass your PDF file:
import * as React from "react";
import { Document, Page } from "react-pdf";
import { pdfjs } from "react-pdf";
import "react-pdf/dist/esm/Page/AnnotationLayer.css";
import "react-pdf/dist/esm/Page/TextLayer.css";
import samplePDF from "./assets/lorem-ipsum.pdf";
pdfjs.GlobalWorkerOptions.workerSrc = "https://unpkg.com/[email protected]/build/pdf.worker.min.js";
const ReactPDF = () => {
return (
<Document file={ samplePDF }>
<Page pageNumber={ 1 } />
</Document>
);
};
Because viewing a PDF changes the current page, add state management for the page number:
import { useState } from "react";
// ...
const ReactPDF = () => {
const [currentPage, setCurrentPage] = useState(1);
return (
<Document file={ samplePDF }>
<Page pageNumber={ currentPage } />
</Document>
);
};
Pagination without navigation is incomplete. Get the document's total page count from Document's onLoadSuccess callback:
// ...
const ReactPDF = () => {
const [pageNumber, setPageNumber] = useState(null);
const [currentPage, setCurrentPage] = useState(1);
const handleLoadSuccess = ({ numPages }) => {
setPageNumber(numPages);
};
return (
<Document file={ samplePDF } onLoadSuccess={ handleLoadSuccess }>
<Page pageNumber={ currentPage } />
</Document>
);
};
Finally, add "Previous" and "Next" controls and show the current position:
// ...
const ReactPDF = () => {
const [currentPage, setCurrentPage] = useState(1);
const [pageNumber, setPageNumber] = useState(null);
const handlePrevious = () => {
// checks if it isn't the first page
if (currentPage > 1) {
setCurrentPage(currentPage - 1);
}
};
const handleNext = () => {
// checks if it isn't the last page
if (currentPage < pageNumber) {
setCurrentPage(currentPage + 1);
}
};
const handleLoadSuccess = ({ numPages }) => {
setPageNumber(numPages);
};
return (
<div>
<Document file={ samplePDF } onLoadSuccess={ handleLoadSuccess }>
<Page pageNumber={ currentPage } />
</Document>
<button onClick={ handlePrevious }>Previous</button>
<p>{currentPage}</p>
<button onClick={ handleNext }>Next</button>
</div>
);
};
Out-of-the-Box UI With react-pdf-viewer
Similar to react-pdf but with the viewer UI already included, react-pdf-viewer trims away work such as wiring pagination controls manually. Install it as:
npm i @react-pdf-viewer/[email protected] @react-pdf-viewer/default-layout
It also rides on pdf.js, so it needs a worker — here provided as a Worker component whose workerUrl points into the package:
import * as React from "react";
import { Worker } from "@react-pdf-viewer/core";
const ReactPDFViewer = () => {
return (
<>
<Worker workerUrl="https://unpkg.com/[email protected]/build/pdf.worker.min.js"></Worker>
</>
);
};
Set the worker once at the layout level, particularly if multiple pages render PDFs.
Then drop the Viewer component in, pointing at the file:
import * as React from "react";
import { Viewer, Worker } from "@react-pdf-viewer/core";
import "@react-pdf-viewer/core/lib/styles/index.css";
import samplePDF from "./assets/lorem-ipsum.pdf";
const ReactPDFViewer = () => {
return (
<>
<Viewer fileUrl={ samplePDF } />
<Worker workerUrl="https://unpkg.com/[email protected]/build/pdf.worker.min.js"></Worker>
</>
);
};
Add the default layout to enable controls — import defaultLayoutPlugin, its styles, instantiate it, and pass the instance to Viewer's plugins prop:
import * as React from "react";
import { Viewer, Worker } from "@react-pdf-viewer/core";
import { defaultLayoutPlugin } from "@react-pdf-viewer/default-layout";
import "@react-pdf-viewer/core/lib/styles/index.css";
import "@react-pdf-viewer/default-layout/lib/styles/index.css";
import samplePDF from "./assets/lorem-ipsum.pdf";
const ReactPDFViewer = () => {
const defaultLayoutPluginInstance = defaultLayoutPlugin();
return (
<>
<Viewer fileUrl={ samplePDF } plugins={ [defaultLayoutPluginInstance] } />
<Worker workerUrl="https://unpkg.com/[email protected]/build/pdf.worker.min.js"></Worker>
</>
);
};
A Package To Skip: react-file-viewer
Another plugin, react-file-viewer, does support PDFs along with images, video, documents, and spreadsheets behind one simple interface:
import * as React from "react";
import FileViewer from "react-file-viewer";
const PDFReactFileViewer = () => {
return <FileViewer fileType="pdf" filePath="/lorem-ipsum.pdf" />;
};
Despite its convenience, the package is badly outdated and compatibility today is shaky. Stick with an iframe, react-pdf, or react-pdf-viewer.
3D Models In Gatsby
3D model files describe geometry, texture, shading, and other object properties for interactive scenes. Common on the web in product visualizations, walkthroughs, and simulations, 3D assets exist in formats such as glTF, OBJ, FBX, and STL. We'll use glTF, the GL Transmission Format designed with the web and real-time apps in mind. Its webpack loader requirements can be bypassed by placing the model in /static. Two Three.js-based routes follow: vanilla or React-wrapped.
Vanilla Three.js
Three.js renders interactive 3D graphics inside <canvas> via WebGL. It has no React or Gatsby integration out of the box, so we'll write glue code around it — covering the library's full API would go beyond scope, so we'll stick to the essentials.
Install the library:
npm i three
Then write a function to load a glTF model. Use the built-in GLTFLoader to instantiate a loader:
import * as React from "react";
import * as THREE from "three";
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
const loadModel = async (scene) => {
const loader = new GLTFLoader();
};
The scene parameter attaches the model to the 3D scene once decoding finishes. The loader's load() method takes four arguments: the file location, a success callback, an in-progress callback, and an error callback:
import * as React from "react";
import * as THREE from "three";
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
const loadModel = async (scene) => {
const loader = new GLTFLoader();
await loader.load(
"/strawberry.gltf", // glTF file location
function (gltf) {
// called when the resource is loaded
scene.add(gltf.scene);
},
undefined, // called while loading is in progress, but we are not using it
function (error) {
// called when loading returns errors
console.error(error);
}
);
};
Create a host component. Reading the element's clientWidth and clientHeight through useRef gives the correct dimensions:
import * as React from "react";
import * as THREE from "three";
import { useRef, useEffect } from "react";
// ...
const ThreeLoader = () => {
const viewerRef = useRef(null);
return <div style={ { height: 600, width: "100%" } } ref={ viewerRef }></div>; // Gives the element its dimensions
};
Because those client properties only exist in the browser, build the scene inside useEffect, configuring camera, WebGL renderer, and lights there:
useEffect(() => {
const { current: viewer } = viewerRef;
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, viewer.clientWidth / viewer.clientHeight, 0.1, 1000);
const renderer = new THREE.WebGLRenderer();
renderer.setSize(viewer.clientWidth, viewer.clientHeight);
const ambientLight = new THREE.AmbientLight(0xffffff, 0.4);
scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff);
directionalLight.position.set(0, 0, 5);
scene.add(directionalLight);
viewer.appendChild(renderer.domElement);
renderer.render(scene, camera);
}, []);
Then invoke loadModel, passing the scene:
useEffect(() => {
const { current: viewer } = viewerRef;
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, viewer.clientWidth / viewer.clientHeight, 0.1, 1000);
const renderer = new THREE.WebGLRenderer();
renderer.setSize(viewer.clientWidth, viewer.clientHeight);
const ambientLight = new THREE.AmbientLight(0xffffff, 0.4);
scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff);
directionalLight.position.set(0, 0, 5);
scene.add(directionalLight);
loadModel(scene); // Here!
viewer.appendChild(renderer.domElement);
renderer.render(scene, camera);
}, []);
Finish with OrbitControls so users can spin and zoom the model:
import * as React from "react";
import * as THREE from "three";
import { useRef, useEffect } from "react";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
const loadModel = async (scene) => {
const loader = new GLTFLoader();
await loader.load(
"/strawberry.gltf", // glTF file location
function (gltf) {
// called when the resource is loaded
scene.add(gltf.scene);
},
undefined, // called while loading is in progress, but it is not used
function (error) {
// called when loading has errors
console.error(error);
}
);
};
const ThreeLoader = () => {
const viewerRef = useRef(null);
useEffect(() => {
const { current: viewer } = viewerRef;
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, viewer.clientWidth / viewer.clientHeight, 0.1, 1000);
const renderer = new THREE.WebGLRenderer();
renderer.setSize(viewer.clientWidth, viewer.clientHeight);
const ambientLight = new THREE.AmbientLight(0xffffff, 0.4);
scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff);
directionalLight.position.set(0, 0, 5);
scene.add(directionalLight);
loadModel(scene);
const target = new THREE.Vector3(-0.5, 1.2, 0);
const controls = new OrbitControls(camera, renderer.domElement);
controls.target = target;
viewer.appendChild(renderer.domElement);
var animate = function () {
requestAnimationFrame(animate);
controls.update();
renderer.render(scene, camera);
};
animate();
}, []);
<div style={ { height: 600, width: "100%" } } ref={ viewerRef }></div>;
};
React Three Fiber
react-three-fiber bridges Three.js and React, managing scene composition with less manual bookkeeping. Install it, adding the @react-three/drei companion library for controls:
npm i react-three-fiber @react-three/drei
This abstraction often rewrites a substantial vanilla scene into much leaner code while preserving the behavior:
import * as React from "react";
import { useLoader, Canvas } from "@react-three/fiber";
import { OrbitControls } from "@react-three/drei";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader";
const ThreeFiberLoader = () => {
const gltf = useLoader(GLTFLoader, "/strawberry.gltf");
return (
<Canvas camera={ { fov: 75, near: 0.1, far: 1000, position: [5, 5, 5] } } style={ { height: 600, width: "100%" } }>
<ambientLight intensity={ 0.4 } />
<directionalLight color="white" />
<primitive object={ gltf.scene } />
<OrbitControls makeDefault />
</Canvas>
);
};
The payoff is clear: same 3D result in fewer steps, cleaner component structure, and fewer Three.js internals to track.
Two Final Tips
The /static Folder Versus Webpack Bundling
Importing assets as modules gets you bundling, minification, and path hashing. Still, two cases favor the static folder at the project root instead:
- Referencing a library outside the bundling pipeline to avoid webpack incompatibilities or missing loaders;
- Serving assets that must keep a stable name, such as entries in a web manifest.
Gatsby's own documentation on the static folder details the mechanics.
On Trusting Third-Party Embeds
Replaced content such as <iframe> is only as trustworthy as its host. Without control of the source, pages may become exposed to iframe injection and cross-frame scripting. Independent of security, an unstable third-party service can degrade or break the user experience when its embed goes down or slows. Vet any external source before including it.



