Password Protecting Gatsby Content
November 22, 2025 | 13 minute read
Gatsby is great at turning everything into static HTML—which is exactly what you don’t want when some posts should be behind a password. In this post I walk through a practical, Firebase-backed pattern for selectively protecting MDX content in a Gatsby 5.
If you've ever tried to make just a few Gatsby pages private, you already know it feels like swimming upstream. Gatsby really wants to turn everything into static HTML and ship it to the world, which is exactly what we don't want for restricted content.
Gatsby normally compiles all MDX files into static HTML at build time. This is great for performance and SEO, but it means every piece of content exists in plain text in your build output. For restricted content, we need a different approach: prevent Gatsby from compiling restricted MDX files at build time, then use client-side routing and runtime MDX compilation to render them only after authentication passes.
Because Gatsby is a static site generator, there’s no real “server” to ask, “does this person know the password?” That’s why we lean on an external authentication provider (Firebase) to do the boring-but-critical work of validating passwords and remembering who’s logged in.
Architecture Overview
Here’s the high-level flow when a user visits a restricted page:
User → Restricted URL (/work/restricted/my-post/)
→ Gatsby serves a single /restricted/index.html shell
→ JavaScript bootstraps
→ Firebase auth check
→ If auth OK: compile MDX in browser + render
→ Else: show login dialogThe key idea to keep in your head: restricted content never exists as static HTML. It only turns into real React/HTML in the browser, after authentication succeeds. Until then, it's just inert MDX sitting in your source, never baked into the build output.
Now you may be thinking: "But that means the content is still available in plain text in the Javscript files!" And you'd be right. A more secure way to do this is to encrypt the content string inside the Javascript bundle using a build secret during build. Then client-side when rendering the page we retrieve the same build secret from Firebase and use it to decrypt the content before inserting it into the webpage. This would be much more secure, however for my purposes here which was really to avoid web-crawlers and casual readers from accessing sensitive content, I find this solution sufficient.
Requirements
Before we get into code, here’s the shape of the problem I was trying to solve. I wanted to ensure some (but not all) of my posts couldn't be accessed, or indexed by Google, without a password. My requirements were:
- All posts are presented together in a single view
- All posts can be sorted and filtered using the same tag systems
- Restricted posts live in a separate directory for easy organization
- Restricted posts require a password to access
- All restricted posts use the same password
- Creating a restricted post is as easy as creating a public one (simply make an MDX file in the right directory)
- Writing a restricted post requires no special syntax or approaches. All the same components can be used in the MDX file. Images are imported the same way. etc.
- Restricted content is never compiled ahead of time or delivered in plain text inside a Javascript package. Public content is compiled ahead of time for faster loading and better SEO.
- Web crawlers are not able to access restricted content and index it, but can access and index public content.
Prerequisites
This isn’t meant to be a Gatsby-from-scratch tutorial. I’m assuming you already have a site running and are comfortable editing gatsby-config.js, gatsby-node.js, and a couple of React components.
Before diving into the implementation, you'll need:
- A Gatsby 5 site with MDX support
- A Firebase project with Authentication enabled
- Environment variables configured for Firebase
@gatsbyjs/reach-routerand@mdx-js/mdxpackages installed
Minimal Starter Template
Let’s start with a minimal “kit” you can drop into an existing Gatsby project. This is the skeleton I wish I’d had when I started instead of slowly gluing it together over a weekend.
Here's the minimal structure you'll need:
src/
content/
posts/
restricted/
my-secret-post.mdx
templates/
restricted/
restricted.js
contexts/
AuthContext.jsx
components/
PrivateRoute/
PrivateRoute.jsx
LoginDialog/
LoginDialog.jsx
services/
auth.js
gatsby-node.js
gatsby-browser.js
gatsby-ssr.js
gatsby-config.jsMinimal gatsby-node.js
The core of the solution is preventing Gatsby from creating static pages for restricted content and instead funneling everything under /restricted/* through a single client-side route. We can do this inside gatsby-node.js like so:
exports.createPages = async ({ graphql, actions }) => {
const { createPage } = actions;
//Get all MDX pages
const result = await graphql(`
query {
allMdx {
edges {
node {
id
fields { slug }
}
}
}
}
`);
// Create a single client-side route for all restricted pages
createPage({
path: "/restricted/",
component: path.resolve(__dirname, "src/templates/restricted/restricted.js"),
matchPath: "/restricted/*", // Matches /restricted/anything/here
});
// Create pages for all NON-restricted content
result.data.allMdx.edges.forEach(({ node }) => {
if (!node.fields.slug.match(/^\/restricted/)) {
createPage({
path: node.fields.slug,
component: path.resolve(__dirname, "src/templates/post/template.jsx"),
context: { id: node.id }
});
}
});
};Minimal AuthProvider
The AuthProvider wraps your app and manages Firebase authentication state, so the rest of your components can just ask “am I logged in?” instead of worrying about Firebase details:
// src/contexts/AuthContext.jsx
import React, { createContext, useContext, useState, useEffect } from 'react';
const AuthContext = createContext({ user: null, loading: true });
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (typeof window === 'undefined') return;
const { initializeApp } = require('firebase/app');
const { getAuth, onAuthStateChanged } = require('firebase/auth');
const app = initializeApp(firebaseConfig);
const auth = getAuth(app);
onAuthStateChanged(auth, (firebaseUser) => {
setUser(firebaseUser);
setLoading(false);
});
}, []);
return (
<AuthContext.Provider value={{ user, loading }}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => useContext(AuthContext);Wrap your app in gatsby-browser.js and gatsby-ssr.js so every page has access to auth state:
import { AuthProvider } from '~contexts/AuthContext';
export const wrapRootElement = ({ element }) => {
return <AuthProvider>{element}</AuthProvider>;
};Minimal Restricted Template
The restricted template is where the magic happens. It looks at the current URL, finds the matching MDX node, and compiles it on the fly once the user is authenticated:
// src/templates/restricted/restricted.js
import { Router } from "@gatsbyjs/reach-router";
import { evaluate } from '@mdx-js/mdx';
import PrivateRoute from "~components/PrivateRoute";
const RestrictedPage = ({ node }) => {
const [CompiledContent, setCompiledContent] = useState(null);
useEffect(() => {
const compile = async () => {
const compiled = await evaluate(node.body, {
remarkPlugins: [remarkSlug, remarkGfm],
useMDXComponents: () => defaultComponents,
});
setCompiledContent(() => compiled.default);
};
compile();
}, [node.body]);
return CompiledContent ? <CompiledContent /> : <Loading />;
};
const Restricted = ({ data }) => {
const currentPath = typeof window !== 'undefined'
? window.location.pathname
: '';
const matchingNode = data.allMdx.edges.find(
({ node }) => node.fields.slug === currentPath
);
return (
<Router>
<PrivateRoute
component={() => <RestrictedPage node={matchingNode.node} />}
/>
</Router>
);
};Example Restricted MDX File
Restricted posts work exactly like regular posts. That was one of my non-negotiables: no special syntax, no weird component wrappers, just MDX:
---
title: "My Secret Post"
date: "2025-01-01"
---
# This is a restricted post
Regular MDX content here. Images, components, everything works the same.
import MyImage from './image.jpg';
<MyComponent />Note: The full implementation includes error handling, media context, static file preprocessing, and more.
Project Structure
Once you have the minimal pieces in place, you’ll probably want to tighten up your content organization. Configure your filesystem sources in gatsby-config.js to separate restricted content:
{
resolve: `gatsby-source-filesystem`,
options: {
name: `posts`,
path: `${__dirname}/src/content/posts/`,
ignore: [`${__dirname}/src/content/posts/restricted/**`]
},
},
{
resolve: `gatsby-source-filesystem`,
options: {
name: `restrictedPosts`,
path: `${__dirname}/src/content/posts/restricted/`
}
},This creates two separate source instances: one for public content and one for restricted content. The restricted content will have slugs like /restricted/[post_name] while public content uses /[post_name].
How It Works: The Details
Preventing Static Compilation
The first trick is simply not creating pages for restricted content during the build. If Gatsby never generates /restricted/* HTML files, there’s nothing for crawlers (or curious users) to hit directly:
// Create pages for all non-restricted content
result.data.allMdx.edges.forEach(({ node }) => {
if (!node.fields.slug.match(/^\/restricted/)) {
createPage({
path: node.fields.slug,
component: path.resolve(templateByInstanceName(node)),
context: { id: node.id }
});
}
});Then we create a single client-side route that handles all restricted paths and delegates the rest to React at runtime:
createPage({
path: "/restricted/",
component: path.resolve(__dirname, "src/templates/restricted/restricted.js"),
matchPath: "/restricted/*", // Matches /restricted/anything/here
});Client-Side MDX Compilation
Once the user is allowed in, we still don’t have pre-compiled content to show them. Instead, we compile the MDX in the browser using @mdx-js/mdx:
const compiled = await evaluate(processedBody, {
remarkPlugins: [remarkSlug, remarkGfm],
useMDXComponents: () => defaultComponents,
Fragment: jsxRuntime.Fragment,
jsx: jsxRuntime.jsx,
jsxs: jsxRuntime.jsxs,
});
setCompiledContent(() => compiled.default);Authentication Flow
All of this is wrapped in a small PrivateRoute component that decides whether a user should see content or a login dialog:
const PrivateRoute = ({ component: PathComponent }) => {
const { user, loading } = useAuth();
if (loading) return <Loading />;
if (!user) return <LoginDialog />;
return <PathComponent />;
};Login Dialog Component
The login dialog itself is just a small React component that wires the password input up to Firebase via handleLogin and shows any errors. Here’s a stripped-down version that captures the important ideas:
import React, { useState, useRef, useEffect } from 'react';
import { navigate } from 'gatsby';
import { handleLogin } from '~services/auth';
const LoginDialog = ({ redirectPath }) => {
const [password, setPassword] = useState('');
const [checkingLogin, setCheckingLogin] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const inputRef = useRef(null);
useEffect(() => {
if (inputRef.current) {
inputRef.current.focus();
}
}, []);
const onSuccess = () => {
setCheckingLogin(false);
setPassword('');
navigate(redirectPath);
};
const onFailure = (error) => {
setErrorMessage(error.message);
setCheckingLogin(false);
};
const submitLogin = (event) => {
event.preventDefault();
setCheckingLogin(true);
handleLogin(password, onSuccess, onFailure);
};
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="login-title"
>
<h2 id="login-title">Password Required</h2>
<form onSubmit={submitLogin}>
<input
ref={inputRef}
type="password"
name="password"
placeholder="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
aria-describedby="password-error"
aria-invalid={!!errorMessage}
/>
<button type="submit" disabled={checkingLogin}>
{checkingLogin ? 'Checking…' : 'Submit'}
</button>
</form>
{errorMessage && (
<p id="password-error" aria-live="assertive">
{errorMessage}
</p>
)}
</div>
);
};My real implementation adds styling, copy, and some extra niceties, but if you can get this version working you have everything you need to plug into the rest of the system.
Webpack Configuration
Finally, we teach Gatsby’s webpack config to pretend Firebase doesn’t exist during the HTML build stage, so the server-side render doesn’t crash trying to access window:
// gatsby-node.js
exports.onCreateWebpackConfig = ({ stage, loaders, actions }) => {
if (stage === "build-html") {
actions.setWebpackConfig({
module: {
rules: [
{
test: /firebase/,
use: loaders.null(),
},
],
},
});
}
};Why Firebase?
I chose Firebase because it's what I set up six years ago, but there are alternatives:
- Netlify Identity: Great if you're already on Netlify, but requires Netlify hosting
- Cloudflare Access: Enterprise-grade, but overkill for personal blogs
- NextAuth.js: Excellent for Next.js, but not designed for static sites
- Auth0: More features than needed, more complex setup
Firebase works well because it's simple, handles session persistence automatically, and doesn't require server-side code. For a static site protecting personal blog posts, it's a solid choice. If I were building a multi-user SaaS tomorrow I might reach for something else, but for “please don’t index my case studies,” Firebase is perfectly adequate.
Firebase Setup
If you’ve never touched Firebase before, the setup looks scarier than it is in practice. At a high level: create a project, enable Email/Password auth, create a single user, and wire up some env vars.
Set up a Firebase project at console.firebase.google.com and configure Authentication. Create a user manually with an email and password, then add these environment variables:
FIREBASE_API_KEY=...
FIREBASE_AUTH_DOMAIN=...
FIREBASE_DATABASE_URL=...
FIREBASE_PROJECT_ID=...
FIREBASE_USER_EMAIL=...- API Key: Found in Google Cloud APIs & Services (Browser key is auto-created)
- Auth Domain: Found in Firebase Project Settings → Authorized Domains
- Database URL: Format is
https://[project_id].firebaseio.com - Project ID: Found in Firebase Project Settings → General
- User Email: The email you used when creating the Firebase user
Caveats and Security Notes
This is the part where I gently but firmly tell you what this setup is not.
This is NOT cryptographic security. This approach:
- ✅ Prevents casual browsing and search engine indexing
- ✅ Keeps content out of static build artifacts
- ❌ Does NOT prevent determined attackers from inspecting network traffic
- ❌ Does NOT prevent users from viewing compiled MDX in browser DevTools
- ❌ Does NOT prevent browser caching of compiled content
Firebase Browser API keys are safe to expose. They're designed to be public. Restrict them by domain in Firebase Console for additional protection.
Best for: Hiding personal blog posts, draft content, or client work.
Not suitable for: Protecting intellectual property, financial data, production databases, or anything that would make you sweat if it leaked. If you’re storing secrets that could get you fired, you probably want a different stack entirely.
Troubleshooting
Here are the problems I actually ran into while upgrading this site, and how I fixed them. If you hit something weird, chances are it rhymes with one of these.
"Firebase auth is undefined during build"
Problem: Firebase code is running during SSR/build.
Solution: Ensure all Firebase calls are wrapped in typeof window !== 'undefined' checks, and configure the webpack null loader as shown above.
"Restricted pages compile to static HTML unexpectedly"
Problem: The createPage filter isn't working correctly.
Solution: Check that your slug pattern matches exactly. Use console.log to verify which pages are being created:
result.data.allMdx.edges.forEach(({ node }) => {
console.log('Creating page:', node.fields.slug);
if (!node.fields.slug.match(/^\/restricted/)) {
// ...
}
});"Client-side routing isn't matching paths"
Problem: matchPath isn't working or location context is stale.
Solution: Use window.location.pathname as a fallback:
const currentPath = typeof window !== 'undefined'
? window.location.pathname
: location?.pathname || '';"MDX images aren't loading after client-side compile"
Problem: Webpack aliases like ~static/ don't work in client-side compilation.
Solution: Pre-process MDX to replace import statements with public URLs:
const preprocessMdxBody = (body) => {
// Replace ~static/file.pdf with /static/file.pdf
return body.replace(/import\s+(\w+)\s+from\s+['"]~static\/([^'"]+)['"];?/g, '');
// Then replace {varName} with actual URL strings
};Why I Needed This
I first created this website and blog back in 2020. It's been almost six years since then, which is horrifying. At the time, I wrestled with the challenge of protecting some of my content behind a password. I recall that this seemed easy at first in Gatsby 3, but ended up becoming a complete headache to get right. By the end, I had created a monster that I didn't fully understand but worked. I published this site and moved on hoping to never have to think about that mess of code again.
Fast-forward to 2025: I decided to dust the place off and start publishing again, which meant dragging the whole thing up to Gatsby 5. Cursor handled most of the boring bits, but my authentication flow exploded spectacularly. I spent hours wandering in circles with it until we finally landed on the approach you’ve just read.
My hope is that you get to skip the wandering-in-circles phase and go straight to “ah, okay, that makes sense.” If you do end up building your own version of this, I’d love to hear how you adapted it.

