+1 (415) 779-8456

Authentication and Gated Content on a Gatsby Site: Client-Only Routes, Sessions, and the Edge

Every static site eventually gets the same request: "can part of it be members-only?" A client portal, a pricing calculator behind a login, gated docs for paying customers, a partner area. The instinct on a Gatsby project is to reach for a client-only route, drop in an auth SDK, and call it done. That works for the demo and fails the first security review, because a client-only route is not access control — it is a display: none with extra steps.

This tutorial covers how we actually build gated content on Gatsby sites: what belongs in the static bundle, what has to move to an edge or serverless boundary, and the three failure modes that show up in audits.

The rule that decides everything

If the content ships in the build output, it is public. Not "hard to find" — public. Gatsby writes every page to public/, and Gatsby's page data lives in predictable JSON files:

npx gatsby build
ls public/page-data/members/dashboard/
# page-data.json
curl -s https://www.example.com/page-data/members/dashboard/page-data.json | head -c 400

If your gated copy, your pricing tiers, or your customer list is in that JSON, no amount of <PrivateRoute> in React will hide it. Anyone can fetch the file directly; no JavaScript runs, no auth check fires.

So the first design question is not "which auth library" — it is which of these three buckets does each piece of gated content fall into?

BucketExampleWhere it lives
Public content, personalised chromeMarketing page that says "Hi, Dana" when logged inStatic build + client-side fetch
Non-secret but paywalledCourse videos, member-only guidesStatic build, but assets served through a gated origin
Actually secretInvoices, client project data, API keysNever in the build — server/edge only

Most projects get into trouble by putting bucket-3 content in bucket-1's implementation.

Bucket 1: client-only routes, done correctly

Client-only routes are the right tool when the shell is static and the data comes from an authenticated API at runtime. Gatsby's createPages supports this with matchPath:

// gatsby-node.js
exports.createPages = async ({ actions }) => {
  const { createPage } = actions;
  createPage({
    path: '/app',
    matchPath: '/app/*',
    component: require.resolve('./src/templates/app-shell.js'),
  });
};

That produces a single static shell at /app that also answers for /app/billing, /app/settings, and so on. Inside it, route with a client-side router and gate on a real session:

// src/templates/app-shell.js
import { Router } from '@reach/router';
import { useAuth } from '../auth/use-auth';
import Login from '../app/login';
import Billing from '../app/billing';

const Guard = ({ children }) => {
  const { status, login } = useAuth();
  if (status === 'loading') return <p>Checking session…</p>;
  if (status === 'anonymous') return <Login onLogin={login} />;
  return children;
};

export default function AppShell() {
  return (
    <Guard>
      <Router basepath="/app">
        <Billing path="/billing" />
      </Router>
    </Guard>
  );
}

Two things make or break this:

Your host must serve the shell for deep links. A direct hit on /app/billing is a 404 on most static hosts unless you add a rewrite. On Netlify:

# static/_redirects
/app/*  /app/index.html  200

On Cloudflare Pages, _redirects works the same way; on S3+CloudFront you need a function or a custom error-document mapping. Test deep links in a real deploy preview, not just gatsby develop, which handles matchPath for you and hides the problem.

The build must not blow up on window. SSR of the shell happens at build time in Node. Guard anything that touches browser globals:

if (typeof window === 'undefined') return null;

or lazy-load the auth SDK inside useEffect. The classic WebpackError: ReferenceError: window is not defined during gatsby build is almost always an auth SDK imported at module scope.

Getting the session right

Token handling is where most implementations quietly go wrong. Three rules we hold to:

  1. No access tokens in localStorage if you can avoid it. Any XSS on the site — including one in a third-party script you did not write — reads it. Prefer an HttpOnly; Secure; SameSite=Lax cookie set by your auth provider or your own token-exchange endpoint.
  2. Validate on the server, every request. The client-side check is UX. The API must independently verify the JWT signature, iss, aud, and exp on each call.
  3. Handle the redirect-back properly. Store the intended path before redirecting to the identity provider and restore it after, or every login dumps the user on the dashboard root.

A minimal hook against a hosted provider:

// src/auth/use-auth.js
import { useEffect, useState } from 'react';

export function useAuth() {
  const [status, setStatus] = useState('loading');
  const [user, setUser] = useState(null);

  useEffect(() => {
    let cancelled = false;
    // Cookie-based session: ask the API who we are.
    fetch('/api/me', { credentials: 'include' })
      .then((r) => (r.ok ? r.json() : null))
      .then((u) => {
        if (cancelled) return;
        setUser(u);
        setStatus(u ? 'authenticated' : 'anonymous');
      })
      .catch(() => !cancelled && setStatus('anonymous'));
    return () => { cancelled = true; };
  }, []);

  const login = () => {
    sessionStorage.setItem('returnTo', window.location.pathname);
    window.location.href = '/api/auth/login';
  };

  return { status, user, login };
}

Note what is not here: no token in component state that gets serialised into a prop, no dangerouslySetInnerHTML of user data, no secret in GATSBY_-prefixed env vars. Anything prefixed GATSBY_ is inlined into the client bundle. Put an API secret there and you have published it. Use unprefixed env vars in gatsby-node.js and serverless functions only, and grep your build output before you ship:

grep -rEo "sk_live_[A-Za-z0-9]+|AIza[0-9A-Za-z_-]{35}" public/ | head

Bucket 3: content that must never be in the build

For genuinely private data, the page can be static but the data must not be. Two patterns, both compatible with a Gatsby build:

Serverless function as the data boundary. Gatsby Functions (src/api/*.js) work on Netlify and Gatsby-compatible hosts; on Cloudflare Pages you use functions/ instead. Either way the shape is the same:

// src/api/invoices.js
import { verifySession } from '../server/session';

export default async function handler(req, res) {
  const session = await verifySession(req); // throws or returns { userId }
  if (!session) return res.status(401).json({ error: 'unauthorized' });

  const invoices = await db.invoices.forUser(session.userId);
  res.setHeader('Cache-Control', 'private, no-store');
  res.status(200).json(invoices);
}

Cache-Control: private, no-store is not optional. A CDN in front of your functions will happily cache one customer's response and serve it to the next if you let it — that is the single worst bug in this category and it is trivially avoidable.

Edge middleware as the gate. If you must serve pre-rendered gated pages (a paywalled guide, say), build them into a path the CDN does not serve publicly and put an edge function in front:

// Cloudflare Pages Functions: functions/members/[[path]].js
export async function onRequest(context) {
  const session = await verifyCookie(context.request, context.env.SESSION_SECRET);
  if (!session) {
    return Response.redirect(new URL('/login', context.request.url), 302);
  }
  const res = await context.next();
  const out = new Response(res.body, res);
  out.headers.set('Cache-Control', 'private, no-store');
  return out;
}

Then confirm that the underlying asset is not reachable around the gate — including its page-data.json and any image or PDF in static/. Files in static/ are copied verbatim to the site root and are always public.

Keep gated routes out of search and sitemaps

Client-only routes have no meaningful content for a crawler, and gated pages should not be indexed at all:

// gatsby-config.js
{
  resolve: 'gatsby-plugin-sitemap',
  options: {
    excludes: ['/app/*', '/members/*', '/login', '/thanks'],
  },
},

Add noindex on the shell itself via the Head API:

export const Head = () => (
  <>
    <title>Account</title>
    <meta name="robots" content="noindex, nofollow" />
  </>
);

And disallow the paths in robots.txt. Three layers, because each one catches a case the others miss.

Test the boundary, not the button

The tests that matter are the ones that call the API without a session. Add them to CI:

// tests/auth-boundary.spec.js
import { test, expect } from '@playwright/test';

const BASE = process.env.BASE_URL;

test('deep link to a gated route renders the shell, not the data', async ({ page }) => {
  await page.goto(`${BASE}/app/billing`);
  await expect(page.getByText(/sign in/i)).toBeVisible();
});

test('API rejects anonymous requests', async ({ request }) => {
  const res = await request.get(`${BASE}/api/invoices`);
  expect(res.status()).toBe(401);
});

test('no gated copy leaks into page-data', async ({ request }) => {
  const res = await request.get(`${BASE}/page-data/app/page-data.json`);
  const text = await res.text();
  expect(text).not.toMatch(/invoice|account_number|secret/i);
});

test('gated responses are not cacheable', async ({ request }) => {
  const res = await request.get(`${BASE}/api/me`);
  expect(res.headers()['cache-control']).toContain('no-store');
});

The third and fourth tests are the ones that have caught real problems for us: a content editor adding member data to a shared GraphQL query, and a CDN rule that started caching /api/* after an infrastructure change.

The short version

  • Client-only routes handle routing and UX, never authorisation.
  • If it is in public/, including page-data.json and static/, it is public.
  • Sessions in HttpOnly cookies; validation on every server request; nothing secret in a GATSBY_-prefixed variable.
  • Cache-Control: private, no-store on every authenticated response.
  • Exclude gated paths from the sitemap, noindex them, and disallow them in robots.txt.
  • Test the unauthenticated path in CI, because that is the path an attacker takes.

Done this way, a Gatsby site handles a members area perfectly well — the static build stays fast and cacheable, and the small amount of genuinely dynamic, genuinely private surface sits where it belongs, behind a server that checks.

If you are adding a login, a client portal, or paywalled content to an existing Gatsby site and want the boundary reviewed before it ships, get in touch.