React vs. Gatsby: The Real Question Is the GraphQL Layer
Gatsby adds a GraphQL data layer on top of React. This catalog imports typed data directly instead, with zero GraphQL and zero content plugins.
React is a UI library; Gatsby is a static site generator built on top of it that adds file-based routing, a GraphQL data layer, and a plugin ecosystem for pulling content in at build time. So "React vs. Gatsby" isn't really a choice between two things — you're writing React components either way. The actual decision is whether you want a GraphQL query layer sitting between your data and your components. This storefront answers that with zero: 139 React components import typed data directly, no query language in between.
What each layer actually adds on top of React
| Plain React (this codebase) | Gatsby | |
|---|---|---|
| Static rendering | Built into Next.js's App Router — pages prerender by default, generateStaticParams drives the dynamic ones | Gatsby's own build engine, prerendering every page to static HTML |
| Data access | import { catalog } from "@/data/catalog" — a typed TypeScript array, read directly | Every source (CMS, filesystem, APIs) gets normalized into one GraphQL schema, queried per-page with a graphql tag |
| Content authoring | .mdx files compiled by @next/mdx, imported by an explicit slug → import() map | Usually gatsby-source-filesystem + gatsby-transformer-remark/MDX plugins feeding the GraphQL layer |
| Routing | App Router file conventions ([slug]/page.tsx) | Filesystem routes plus the createPages API in gatsby-node.js for programmatic ones |
| Mixing static and dynamic | Both live in one app: SSG catalog pages next to fully dynamic Server Actions and Route Handlers (checkout, downloads, dashboard), no separate deploy target | Needs Gatsby Functions or Deferred Static Generation bolted on for anything that isn't build-time data |
| Extending to a new data source | Add a TypeScript file, import it | Install and configure a gatsby-source-* plugin, extend the GraphQL schema |
Neither column changes what React itself does — components, props, hooks are identical in both. What changes is everything around React: how pages get their data, and whether that data has to pass through a query language to get there.
What Gatsby's data layer looks like, and what replaces it here
A Gatsby page typically pulls its content through a GraphQL query, even for data with no reason to be schema'd:
// The Gatsby way — this codebase does not do this
export const query = graphql`
query {
allProductsJson {
nodes { slug name category }
}
}
`;
export default function Templates({ data }) {
return data.allProductsJson.nodes.map((p) => <Card key={p.slug} {...p} />);
}
That query layer earns its keep when you're unifying genuinely disparate sources — a headless CMS, a spreadsheet, an Instagram feed, a dozen markdown collections — behind one interface every component can query the same way. This storefront has one source of product data, and it's already TypeScript:
// src/app/templates/page.tsx (real shape)
import { catalog } from "@/data/catalog";
export default function TemplatesPage() {
return catalog.map((product) => (
<TemplateCard key={product.slug} product={product} />
));
}
No schema translation, no query string to keep in sync with a type, no build step that has to run a GraphQL server in-process before it can render a single page. The type system (catalog: TemplateProduct[]) is the same guarantee GraphQL's schema gives you — that a product.slug really exists — enforced by tsc at next build instead of by a query resolver at data-fetch time. For 111 products and 476 static pages total, the entire "data layer" is TypeScript imports; there was never a second interface to build.
The same logic applies to content authoring. Gatsby typically transforms Markdown/MDX files into GraphQL nodes via gatsby-transformer-remark or gatsby-plugin-mdx, then queries them. This site's blog compiles .mdx directly with @next/mdx (remark-gfm for tables, rehype-slug for heading anchors — both named as plugin strings in next.config.ts, since Turbopack runs the pipeline in Rust and drops imported function references) and pairs each slug with its module through an explicit loader map in src/lib/blog.ts. A typo in that map is a build error, not a GraphQL query returning null.
One deploy target, not two execution models
The data layer isn't the only place Gatsby adds a seam. This storefront's build output mixes fully static product/catalog pages with fully dynamic paths — a checkout webhook, a gated download route, an authenticated dashboard, a per-request session refresh in src/proxy.ts (see What Is Supabase Used For for what that refresh actually does) — and none of it needed a second deployment target or a different runtime to add. The static pages prerender at build; the dynamic ones run as Server Actions and Route Handlers on the same Vercel deploy, same framework, same next build.
Gatsby's core model is build-time static generation, full stop — anything that needs to run per-request (an authenticated route, a webhook, a rate-limited download) is a second thing bolted on afterward: Gatsby Functions for serverless endpoints, Deferred Static Generation for pages too numerous or too fresh to prebuild all of, each with its own execution model and its own set of constraints layered on top of the static core. Neither addition is exotic, but they're additions — capabilities the static-first architecture didn't have and had to grow toward, rather than a spectrum the framework was built to span from day one.
That difference matters less for a pure content site — a blog, a marketing microsite with no login — where "everything is static" was already going to be true regardless of framework. It matters more the moment a project needs even a handful of dynamic routes alongside its static bulk, which is exactly this storefront's shape: hundreds of static catalog pages, a low double-digit number of dynamic ones, both shipping from one next build with no separate CI pipeline, hosting account, or plugin to configure for the dynamic half.
Common mistakes
| Mistake | What happens | Fix |
|---|---|---|
| Reaching for Gatsby because you need "static generation" | Next.js, Remix, and even plain React with a static host all prerender — Gatsby isn't the only door to that outcome | Pick the meta-framework by its data model and routing, not by whether it can produce static HTML — most can |
| Treating "React vs. Gatsby" as a real either/or | You write React components in both; the difference is entirely in what wraps them | Frame the decision as "do I want a GraphQL data layer," which is the actual variable |
| Querying local, already-typed data through GraphQL anyway | Adds a schema, a resolver, and a build-time query for data you could have imported directly | Import the array; reserve GraphQL for genuinely federating multiple external sources |
| Bolting a dynamic route onto a Gatsby site | Needs Gatsby Functions or DSG, a second execution model alongside the static build | If a meaningful share of your app is dynamic (auth, checkout, a dashboard), a framework built around mixing both from the start avoids the seam |
| Assuming more plugins means more capability, with no cost | Each gatsby-source-*/gatsby-transformer-* plugin is a dependency to update and a potential build-time failure point | Prefer a data source you can import as code when one exists — fewer moving parts to break on upgrade |
Frequently asked questions
Is "React vs. Gatsby" even a fair comparison? Not literally — Gatsby is built on React, so choosing Gatsby is still choosing React. The real comparison is React alone (or with a lighter meta-framework) versus React plus Gatsby's GraphQL data layer and plugin ecosystem.
When does Gatsby's GraphQL layer actually earn its keep? When you're genuinely unifying several disparate sources — a headless CMS, markdown files, and a couple of third-party APIs — behind one query interface that every component can use identically. If your data already lives in one typed place, as this catalog does, the query layer adds a translation step with nothing to translate.
Do I need Gatsby to build a blog with Markdown/MDX?
No. This site's entire blog — sitemap, RSS feed, OG images, related-posts logic — runs on @next/mdx compiling .mdx files directly, paired with a typed metadata registry. No GraphQL server runs during the build.
Can I still use GraphQL in a plain React or Next.js app if I want to? Yes, with a client like Apollo or urql — it's an available tool, not something Gatsby owns. The point isn't that GraphQL is wrong; it's that it's an added layer you should choose for a reason, not inherit by default because a framework put it in front of you.
Does dropping Gatsby's data layer mean giving up build-time validation?
No — it moves the check from a GraphQL schema to TypeScript's type system, which catches the same class of mistake at the same point in the pipeline. catalog: TemplateProduct[] fails next build the moment a product entry is missing a required field, the same way a query against a changed GraphQL schema would fail Gatsby's build — the validation didn't disappear, it runs as tsc instead of as a query resolver.
Templates in this post
ASoc Edge (an applied-AI agency site), ASoc Fade (a barbershop template) and ASoc Fiscal (a financial platform landing page) all ship on the same data model described above — typed catalog imports, zero GraphQL, zero content plugins.
Browse the full sets: Next.js landing page templates, Tailwind landing page templates. For how this site's own MDX pipeline is wired, see MDX vs a Headless CMS; for the App Router routing conventions actually in use here, see Next.js Routing.
