Personal security research blog and portfolio — built with Gatsby 5, MDX v2, TailwindCSS, and serverless API functions.
- Architecture
- Tech Stack
- Project Structure
- Build Instructions
- Deployment
- Write Panel
- Environment Variables
- Migration Notes: Gatsby 5 + gatsby-plugin-mdx v5
- Troubleshooting
The site is a Gatsby 5 static site with the following data pipeline:
content/
articles/ <- MDX files --+
ctfs/ <- MDX files --+--> gatsby-source-filesystem
pages/ <- MDX + JSON --+ |
v
gatsby-plugin-mdx (v5)
gatsby-transformer-json
gatsby-transformer-sharp
|
v
GraphQL Data Layer
|
v
gatsby-node.js createPages()
+-- src/templates/article.js
+-- src/templates/ctf.js
+-- src/templates/page.js
+-- src/write-page.js (/write)
|
v
Static HTML + JS bundles
-> public/
The project uses GATSBY_RUNTIME to separate static-site mode and write-admin mode:
github-> GitHub Pages build mode./write/is skipped ingatsby-node.js.local-> local development mode. Write API reads/writes local filesystem.netlify-> Netlify runtime mode. Write API uses GitHub REST API and triggersgatsby.yml.
MDX files live in content/ and are sourced via gatsby-source-filesystem. Each file's frontmatter defines its type (article, ctf, page), which determines which template renders it.
gatsby-plugin-mdx v5 compiles MDX to React components. Templates receive the compiled MDX as children via the ?__contentFilePath= mechanism — there is no MDXRenderer or body field (both were removed in v5).
Custom MDX components (links, code blocks, embeds, LatestArticles, AllCtfs, etc.) are injected globally via MDXProvider in src/components/mdx-parser.js. MDX content files must not contain direct import statements — components must be registered in the provider instead.
Gatsby Functions in src/api/ are deployed as serverless endpoints:
| Endpoint | Purpose |
|---|---|
/api/fauna-add-reaction |
Write a reaction to FaunaDB |
/api/fauna-latest-reaction |
Fetch the most recent reaction |
/api/fauna-reaction-by-slug |
Fetch reactions for a page |
/api/newsletter |
Newsletter signup (ConvertKit) |
/api/ua-analytics |
Proxy to Google Analytics Data API |
GET /api/write |
Load an existing article by slug |
POST /api/write |
Create / update article MDX |
DELETE /api/write |
Delete article MDX by slug |
POST /api/write-image |
Upload image and return MDX snippet |
TailwindCSS v3 with @tailwindcss/typography provides all styling. PostCSS + Autoprefixer handle transforms. Custom colours are defined in tailwind.config.js.
| Layer | Technology |
|---|---|
| Framework | Gatsby 5, React 18 |
| Content | MDX v2 (gatsby-plugin-mdx v5, @mdx-js/react v2, @mdx-js/mdx v2) |
| Styling | TailwindCSS 3, PostCSS, Autoprefixer |
| 3D / Viz | Three.js, @react-three/fiber, @react-three/drei, d3-geo, dotted-map |
| Backend | Gatsby Functions, FaunaDB, Google Analytics Data API, GitHub Content API |
| Images | gatsby-plugin-image, gatsby-plugin-sharp |
| Deployment | GitHub Pages (public site) + Netlify (write panel runtime) |
| Code Quality | Prettier, Husky, commitlint |
.
+-- content/
| +-- articles/ # Security article MDX files
| +-- ctfs/ # CTF write-up MDX files (organised by year/month)
| +-- pages/ # Top-level page MDX files (index, about, etc.)
+-- src/
| +-- api/ # Gatsby serverless functions
| | +-- write.js # Article CRUD API (GET/POST/DELETE)
| | +-- write-image.js # Image upload API
| +-- components/ # React components
| +-- context/ # React context (app state)
| +-- hooks/ # Custom React hooks
| +-- pages/ # Static Gatsby pages (404, analytics, test)
| +-- styles/ # Global CSS
| +-- templates/ # Page templates (article, ctf, page)
| +-- utils/ # Utility functions
| +-- write-page.js # /write admin panel
+-- static/ # Static assets (fonts, images, favicon)
+-- gatsby-browser.js # Browser-side Gatsby APIs
+-- gatsby-config.mjs # Gatsby configuration + plugins
+-- gatsby-node.js # Node.js build-time APIs (createPages, schema)
+-- gatsby-ssr.js # SSR-side Gatsby APIs
+-- tailwind.config.js # TailwindCSS configuration
+-- postcss.config.js # PostCSS configuration
- Node.js >= 18 (see
.nvmrc) - Yarn
yarn installGATSBY_RUNTIME=local yarn develop
# Site available at http://localhost:8000
# GraphiQL at http://localhost:8000/___graphql
# Write Panel at http://localhost:8000/write/GATSBY_RUNTIME=github yarn buildOutput is written to public/.
yarn serveyarn clean
# or: gatsby cleanThe repo currently uses two runtime targets:
- GitHub Pages -> public site at
https://z0rs.github.io/ - Netlify -> write-admin runtime (
/write+ Gatsby Functions)
- Checkout source
- Detect package manager (yarn)
- Setup Node 18
- Configure GitHub Pages
yarn install --frozen-lockfile- Clear stale
.cacheandpublic - Build with
GATSBY_RUNTIME=githubandNODE_OPTIONS=--max-old-space-size=4096 - Upload
public/as a Pages artifact - Deploy to GitHub Pages
The site is served from the root domain https://z0rs.github.io/ (no pathPrefix).
/write/ is an admin-style page generated from src/write-page.js.
Capabilities:
- Create article
- Update article (load via Recent Articles -> Edit)
- Delete article (Danger Zone)
- Upload image to
/static/images/uploads/YYYY/MM/
Runtime behavior:
- Local (
GATSBY_RUNTIME=local):- APIs read/write local filesystem.
- You still need manual git commit/push for production changes.
- Netlify (
GATSBY_RUNTIME=netlify):- APIs use GitHub REST API via
GITHUB_TOKEN. - Article/image updates are committed directly to GitHub.
- Site rebuild is triggered by dispatching workflow
gatsby.yml.
- APIs use GitHub REST API via
- GitHub Pages (
GATSBY_RUNTIME=github):/write/is not created at build time.
Auth model:
- Local: any non-empty bearer token is accepted.
- Production runtime: requires
Authorization: Bearer <WRITE_SECRET>.
Copy .env and fill in the values. The file is loaded by dotenv in gatsby-config.mjs.
| Variable | Description |
|---|---|
GATSBY_RUNTIME |
Runtime mode: local, netlify, or github |
WRITE_SECRET |
Bearer token required by /api/write and /api/write-image in production |
GITHUB_TOKEN |
Token used by write APIs to commit files and trigger rebuilds |
GATSBY_API_URL |
Base URL for API calls |
GATSBY_TWITTER_USERNAME |
Twitter/X username for webmentions |
GATSBY_GA_MEASUREMENT_ID |
Google Analytics measurement ID |
CK_FORM_ID |
ConvertKit form ID (newsletter) |
CK_API_KEY |
ConvertKit API key |
FAUNA_KEY |
FaunaDB secret key |
URL / SITE_URL |
Canonical site URL (used in siteMetadata.siteUrl) |
GitHub Actions secrets required for CI: GATSBY_GA_MEASUREMENT_ID, FAUNA_KEY, CK_API_KEY, CK_FORM_ID (and optionally URL/SITE_URL if overriding the default production domain).
For Netlify write runtime, set at minimum:
GATSBY_RUNTIME=netlifyWRITE_SECRET=<strong-random-value>GITHUB_TOKEN=<PAT with contents:write and workflows:write>
This section documents every breaking change resolved during the Gatsby 4 -> Gatsby 5 / gatsby-plugin-mdx v3 -> v5 migration.
"gatsby-plugin-mdx": "^5.0.0",
"@mdx-js/mdx": "^2.3.0",
"@mdx-js/react": "^2.3.0"gatsby-config.mjs change: rehype plugins must be nested under mdxOptions (was options).
gatsby-plugin-mdx v5 removed MDXRenderer and the body GraphQL field. Templates now receive compiled MDX as React children via the ?__contentFilePath= mechanism.
gatsby-node.js createPage call:
const template = path.join(__dirname, `./src/templates/${type}.js`);
component: `${template}?__contentFilePath=${contentFilePath}`,Templates accept children:
// Before (v3):
const Page = ({ data: { mdx: { body } } }) => <MdxParser>{body}</MdxParser>
// After (v5):
const Page = ({ data, children }) => <MdxParser>{children}</MdxParser>MDX v2 imports inside .mdx files are silently dropped by webpack when using ?__contentFilePath=. Any component used in MDX must be registered in MDXProvider in src/components/mdx-parser.js. Remove all import statements from .mdx files.
MDX v2 treats .mdx files as JSX. Constructs valid in MDX v1 that now cause build errors:
| Construct | Fix |
|---|---|
HTML comments <!-- ... --> |
Remove entirely |
Bare < in prose (e.g. <192.168.1.1:80>) |
Wrap in backticks |
CTF flags {flag_value} outside code fences |
Wrap in backticks |
Special chars in filename (&, %, #, ?) |
Rename file; sanitise slug in gatsby-node.js |
Slug sanitisation in gatsby-node.js:
const rawPath = createFilePath({ node, getNode });
const sanitised = rawPath
.replace(/&/g, '-and-')
.replace(/[%#?]/g, '-')
.replace(/-{2,}/g, '-');Calling useStaticQuery inside a Head export from an MDX-backed template causes a crash on large articles. Gatsby's worker-based static query registration fails for large ?__contentFilePath= webpack chunks, leaving the query hash unregistered. At SSR time, useStaticQuery throws The result of this StaticQuery could not be fetched.
Fix: seo.js no longer calls useStaticQuery. All callers pass siteMetadata as a prop sourced from the page-level GraphQL query:
// In each template's GraphQL query:
site {
siteMetadata { name siteUrl defaultImage keywords }
}
// In the Head export:
export const Head = ({ data: { site: { siteMetadata }, mdx: { ... } } }) => (
<Seo ... siteMetadata={siteMetadata} />
);Some articles reference images at inseclab.uit.edu.vn, which has an incomplete TLS chain. Each createRemoteFileNode call is wrapped in try/catch so the build continues gracefully. Affected articles render without a featured image. All image-consuming components guard against null thumbnails with optional chaining.
Components that destructure featuredImage.childImageSharp.thumbnail directly throw if the image was not fetched. All list and card components use safe destructuring:
const thumbnail = featuredImage?.childImageSharp?.thumbnail ?? null;
{thumbnail && <GatsbyImage image={thumbnail} alt={...} />}Common causes:
WRITE_SECRETis missing or wrong.GITHUB_TOKENis missing.GATSBY_RUNTIMEis not set tonetlify.
Check Netlify environment variables and redeploy.
The delete flow requires an existing file SHA from GitHub Contents API.
Typical causes:
- Slug is invalid or does not map to an existing file in
content/articles. - API request path is wrong (
articles/<slug>/instead of raw slug).
Use the article slug only, for example my-post-title.
Symptom:
require() of ES Module remark-gfm ... from gatsby-config.js not supported
Fix: use ESM config (gatsby-config.mjs) with import remarkGfm from 'remark-gfm'.
This usually means schema sources/plugins are not aligned with current config.
Checklist:
- Ensure
gatsby-source-filesystempaths includecontent/articles,content/ctfs, andcontent/pages. - Ensure image plugins are installed and enabled:
gatsby-plugin-image,gatsby-plugin-sharp,gatsby-transformer-sharp. - Run
gatsby cleanand rebuild after config/plugin changes.
Symptom: warning Plugin gatsby-plugin-mdx is not compatible with your gatsby version 5.0.0
Fix:
yarn add gatsby-plugin-mdx@^5 @mdx-js/react@^2 @mdx-js/mdx@^2Symptom:
error Your plugins must export known APIs from their gatsby-node.js.
- The plugin gatsby-plugin-mdx@3.x.x is using the API "unstable_shouldOnCreateNode"
Cause: gatsby-plugin-mdx@3 used an API renamed in Gatsby 5. Fix: Upgrade to gatsby-plugin-mdx@5.
Cause: Both were removed in gatsby-plugin-mdx@5. See Migration Notes §2.
Symptom:
WebpackError: The result of this StaticQuery could not be fetched.
- seo.js:NN
Cause: useStaticQuery inside a Head export on a large MDX-backed page. See Migration Notes §5.
Symptom: ReferenceError: flag_value is not defined
Cause: MDX v2 treats {...} in prose as JSX expressions. Fix: Wrap in backticks: `CTF{flag_value}`
Possible causes:
- Missing
?__contentFilePath=increatePage()— see Migration Notes §2 - Direct
importstatements in.mdxfiles — see Migration Notes §3 - Null
featuredImagecrashing the React tree — see Migration Notes §7
Risk: Disables TLS verification for all outbound HTTPS during the build — a security vulnerability. Fix: Remove the variable from the workflow and wrap individual createRemoteFileNode calls in try/catch instead.
NODE_OPTIONS='--max-old-space-size=4096' gatsby buildgatsby clean && gatsby buildnpx update-browserslist-db@latest