MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

Next.js vs Gatsby: Choosing a React Framework in 2019

Compare Next.js and Gatsby for SSR, SSG, and the best use cases for each.

Next.jsGatsbyReactSSR

By MinhVo

Introduction

In 2019, the React ecosystem reached a pivotal inflection point. React itself had matured into a production-ready library for building user interfaces, but developers increasingly needed more than just a view layer. They needed frameworks that handled routing, server-side rendering, static site generation, and the entire build pipeline out of the box. Two frameworks emerged as the dominant choices: Next.js, created by Vercel (then ZEIT), and Gatsby, the GraphQL-powered static site generator that had captured the imagination of the Jamstack community.

The choice between Next.js and Gatsby in 2019 wasn't merely technical—it represented a philosophical divide in how developers thought about the web. Gatsby championed the idea that every page should be pre-rendered at build time, serving blazing-fast static files from a CDN. Next.js offered a more flexible hybrid approach, supporting both server-side rendering and static generation, letting developers choose the right strategy for each page.

This guide provides an honest, comprehensive comparison of both frameworks as they existed in 2019, examining their architectures, developer experiences, performance characteristics, and ideal use cases. Whether you were building a personal blog, a marketing site, or a complex web application, understanding the strengths and limitations of each framework was essential for making the right choice.

React frameworks comparison

Understanding the Core Architecture

Gatsby's Static-First Philosophy

Gatsby was built around a radical premise: every page of your website should be compiled into static HTML at build time. This approach, known as Static Site Generation (SSG), meant that when a user requested a page, the server simply returned a pre-built HTML file—no database queries, no server-side computation, no cold starts. The JavaScript bundle then "hydrated" the page on the client side, making it interactive.

The architecture centered on three pillars: GraphQL for data sourcing, a unified data layer that could pull from any source (CMS, APIs, markdown files, databases), and webpack-based build pipeline that produced optimized static assets. Gatsby's plugin ecosystem was its crown jewel, with hundreds of source plugins that could connect to virtually any data source.

// gatsby-node.js - Programmatic page creation
const path = require('path');
 
exports.createPages = async ({ graphql, actions }) => {
  const { createPage } = actions;
  const result = await graphql(`
    query {
      allMarkdownRemark {
        edges {
          node {
            fields {
              slug
            }
            frontmatter {
              title
            }
          }
        }
      }
    }
  `);
 
  result.data.allMarkdownRemark.edges.forEach(({ node }) => {
    createPage({
      path: node.fields.slug,
      component: path.resolve('./src/templates/blog-post.js'),
      context: {
        slug: node.fields.slug,
      },
    });
  });
};

Gatsby's GraphQL data layer was both its greatest strength and its most controversial design choice. By forcing all data through GraphQL, Gatsby provided a unified, type-safe way to query data regardless of its source. However, this also added complexity—developers had to learn GraphQL, write queries for simple data fetching, and deal with the GraphQL layer's overhead for projects that didn't need it.

Next.js's Flexible Rendering Strategy

Next.js took a fundamentally different approach by offering multiple rendering strategies within a single framework. In 2019, Next.js 9 introduced automatic static optimization, which could determine at build time whether a page could be pre-rendered or needed server-side rendering. This meant developers didn't have to choose upfront—the framework figured it out for them.

The architecture was built around a file-system-based router, where every file in the pages/ directory automatically became a route. Pages could export a getInitialProps function (the data-fetching mechanism in 2019) to fetch data either at build time or on each request, depending on the deployment target.

// pages/blog/[slug].js - Next.js 9 dynamic routing
import React from 'react';
import fetch from 'isomorphic-unfetch';
 
const BlogPost = ({ post }) => (
  <article>
    <h1>{post.title}</h1>
    <div dangerouslySetInnerHTML={{ __html: post.content }} />
  </article>
);
 
BlogPost.getInitialProps = async ({ query }) => {
  const res = await fetch(`https://api.example.com/posts/${query.slug}`);
  const post = await res.json();
  return { post };
};
 
export default BlogPost;

Next.js's key architectural advantage was its flexibility. The same application could have some pages statically generated (like a blog index), others server-rendered (like a user dashboard), and still others rendered entirely on the client (like a real-time chat). This hybrid approach made Next.js suitable for a broader range of applications.

Architecture diagram

Step-by-Step Implementation: Building a Blog

Gatsby Blog Implementation

Building a blog with Gatsby in 2019 followed a well-established pattern:

# Install Gatsby CLI
npm install -g gatsby-cli
 
# Create a new blog project
gatsby new my-gatsby-blog https://github.com/gatsbyjs/gatsby-starter-blog
 
# Start development server
cd my-gatsby-blog
gatsby develop
// src/pages/index.js - Gatsby blog index
import React from 'react';
import { graphql, Link } from 'gatsby';
 
const BlogIndex = ({ data }) => {
  const posts = data.allMarkdownRemark.edges;
  return (
    <div>
      <h1>Blog</h1>
      {posts.map(({ node }) => (
        <article key={node.id}>
          <Link to={node.fields.slug}>
            <h2>{node.frontmatter.title}</h2>
          </Link>
          <p>{node.frontmatter.date}</p>
          <p>{node.excerpt}</p>
        </article>
      ))}
    </div>
  );
};
 
export const query = graphql`
  query {
    allMarkdownRemark(sort: { fields: [frontmatter___date], order: DESC }) {
      edges {
        node {
          id
          excerpt(pruneLength: 250)
          fields { slug }
          frontmatter {
            title
            date(formatString: "MMMM DD, YYYY")
          }
        }
      }
    }
  }
`;
 
export default BlogIndex;

Next.js Blog Implementation

The same blog built with Next.js in 2019 had a different structure:

# Create a Next.js project
npx create-next-app my-nextjs-blog
cd my-nextjs-blog
npm install gray-matter remark remark-html
// lib/posts.js - Data fetching utilities
import fs from 'fs';
import path from 'path';
import matter from 'gray-matter';
import remark from 'remark';
import html from 'remark-html';
 
const postsDirectory = path.join(process.cwd(), 'posts');
 
export function getSortedPostsData() {
  const fileNames = fs.readdirSync(postsDirectory);
  const allPostsData = fileNames.map((fileName) => {
    const id = fileName.replace(/\.md$/, '');
    const fullPath = path.join(postsDirectory, fileName);
    const fileContents = fs.readFileSync(fullPath, 'utf8');
    const { data } = matter(fileContents);
    return { id, ...data };
  });
 
  return allPostsData.sort((a, b) => (a.date < b.date ? 1 : -1));
}
 
export async function getPostData(id) {
  const fullPath = path.join(postsDirectory, `${id}.md`);
  const fileContents = fs.readFileSync(fullPath, 'utf8');
  const { data, content } = matter(fileContents);
  const processedContent = await remark().use(html).process(content);
  return { id, content: processedContent.toString(), ...data };
}
// pages/index.js - Next.js blog index
import { getSortedPostsData } from '../lib/posts';
import Link from 'next/link';
 
export async function getStaticProps() {
  const allPostsData = getSortedPostsData();
  return { props: { allPostsData } };
}
 
export default function Home({ allPostsData }) {
  return (
    <div>
      <h1>Blog</h1>
      {allPostsData.map(({ id, date, title }) => (
        <article key={id}>
          <Link href={`/posts/${id}`}>
            <h2>{title}</h2>
          </Link>
          <small>{date}</small>
        </article>
      ))}
    </div>
  );
}

The key difference is immediately apparent: Gatsby requires GraphQL for data fetching, while Next.js uses plain JavaScript functions. For a simple blog, Next.js's approach felt more straightforward; for complex sites with multiple data sources, Gatsby's unified GraphQL layer could be more powerful.

Performance Comparison

Build-Time Performance

MetricGatsbyNext.js (SSG)Next.js (SSR)
100 pages45s12sN/A (runtime)
1,000 pages4m 30s1m 15sN/A (runtime)
10,000 pages25m+8mN/A (runtime)
Incremental buildsSupported (Gatsby Cloud)Not available in 2019N/A
Build cachePlugin-basedFilesystem-basedN/A

Gatsby's build times were notoriously slow for large sites. The GraphQL data layer, while powerful, added significant overhead. Every build required querying all data sources, constructing the GraphQL schema, running all queries, and then rendering pages. For a 10,000-page site, this could easily exceed 25 minutes, making iterative development painful.

Runtime Performance

MetricGatsby (Static)Next.js (SSG)Next.js (SSR)
Time to First Byte~50ms (CDN)~50ms (CDN)200-800ms (server)
First Contentful Paint0.8s0.9s1.2s
Time to Interactive1.5s1.8s2.1s
Lighthouse Score95-10090-10080-95
Bundle Size (initial)75KB65KB65KB

Gatsby's aggressive pre-loading strategies gave it a slight edge in runtime performance. The framework automatically prefetches linked pages, so navigating between pages felt nearly instant. However, this came at the cost of larger initial bundles due to the GraphQL runtime.

The Hydration Problem

Both frameworks faced the same fundamental challenge: the initial HTML was rendered on the server (or at build time), but interactivity required downloading, parsing, and executing the JavaScript bundle. This "hydration" step was where most perceived slowness occurred:

// Both frameworks - Hydration-aware component
import React, { useState, useEffect } from 'react';
 
function InteractiveWidget() {
  const [isClient, setIsClient] = useState(false);
  const [data, setData] = useState(null);
 
  useEffect(() => {
    setIsClient(true);
    // Fetch interactive-only data after hydration
    fetch('/api/widget-data').then(res => res.json()).then(setData);
  }, []);
 
  if (!isClient) {
    return <div className="widget-skeleton">Loading...</div>;
  }
 
  return (
    <div className="widget">
      <InteractiveChart data={data} />
    </div>
  );
}

Real-World Use Cases

Use Case 1: Marketing Website — Gatsby Wins

A marketing team needed a 50-page website with content managed through Contentful CMS, heavy use of images, and a requirement for perfect Lighthouse scores. Gatsby was the clear winner because:

  • The gatsby-image plugin provided automatic image optimization, lazy loading, and blur-up placeholders
  • Contentful's Gatsby source plugin made CMS integration seamless
  • Static generation ensured instant page loads from any CDN
  • The plugin ecosystem handled SEO, sitemap generation, and analytics out of the box

Use Case 2: SaaS Dashboard — Next.js Wins

A startup building a SaaS analytics dashboard needed authentication, real-time data, and API routes. Next.js was the clear choice because:

  • Server-side rendering allowed authenticated pages to be rendered per-request
  • API routes eliminated the need for a separate backend server
  • Dynamic routing handled user-specific pages naturally
  • The hybrid rendering model allowed static marketing pages alongside dynamic app pages

Use Case 3: E-Commerce Storefront — Split Decision

An e-commerce site with 500 products and a blog needed both fast product pages and a content marketing strategy. The decision depended on the product catalog's dynamism:

  • Static catalog (products rarely change): Gatsby with Shopify source plugin
  • Dynamic pricing/inventory: Next.js with server-side rendering for product pages, static generation for blog

Best Practices for Production

  1. Choose based on data strategy: If your content is relatively static (blog, docs, marketing), Gatsby's SSG approach is ideal. If your data changes frequently or is user-specific, Next.js's flexibility is essential.

  2. Optimize images aggressively: Both frameworks benefit from responsive images, but Gatsby's gatsby-image was significantly more mature in 2019 than Next.js's image handling.

  3. Implement proper code splitting: Both frameworks support dynamic imports, but you need to use them intentionally for heavy components.

// Next.js dynamic import
import dynamic from 'next/dynamic';
 
const HeavyChart = dynamic(() => import('../components/HeavyChart'), {
  loading: () => <p>Loading chart...</p>,
  ssr: false, // Skip server-side rendering for this component
});
  1. Monitor build times: For Gatsby, set up Gatsby Cloud for incremental builds. For Next.js, use the built-in build analyzer.

  2. Implement proper error boundaries: Both frameworks require error handling for production resilience.

// Error boundary for both frameworks
class ErrorBoundary extends React.Component {
  state = { hasError: false };
 
  static getDerivedStateFromError(error) {
    return { hasError: true };
  }
 
  componentDidCatch(error, errorInfo) {
    console.error('Error caught:', error, errorInfo);
    // Send to error reporting service
  }
 
  render() {
    if (this.state.hasError) {
      return <h1>Something went wrong.</h1>;
    }
    return this.props.children;
  }
}
  1. Use environment variables properly: Gatsby uses GATSBY_ prefix, Next.js uses NEXT_PUBLIC_ prefix for client-side variables.

  2. Implement proper SEO: Both frameworks support head management, but Gatsby's gatsby-plugin-react-helmet was more established than Next.js's <Head> component in 2019.

Common Pitfalls and Solutions

PitfallImpactSolution
Gatsby build times exceeding 10 minutesSlow developer feedback loopUse Gatsby Cloud for incremental builds; reduce GraphQL query complexity; paginate large datasets
Next.js getInitialProps on every page navigationUnnecessary server requestsMove to getStaticProps/getStaticPaths where possible (Next.js 9.3+)
Gatsby GraphQL layer adding 2-3s to buildsSignificant overhead for simple sitesConsider if GraphQL is necessary; for simple data, Next.js's direct file reading may be better
Next.js bundle size bloat from getInitialPropsSlower page loadsSeparate server-only data fetching from client-side props
Gatsby plugin conflictsBuild failuresPin plugin versions; test plugin combinations in isolation
Next.js SSR flicker on page transitionsPoor UXUse next/router events to show loading states; implement proper Suspense boundaries

Comparison with Alternative Approaches

FeatureGatsbyNext.jsCreate React AppNuxt.js (Vue)
RenderingSSG only (2019)SSG + SSR + CSRCSR onlySSG + SSR + CSR
Data FetchingGraphQL requiredgetInitialPropsManualasyncData
Build SpeedSlowFastFastModerate
Image OptimizationExcellent (gatsby-image)Manual in 2019ManualManual
Plugin Ecosystem2,500+ pluginsSmaller but growingMinimalModerate
Learning CurveModerate (GraphQL)LowVery lowModerate
Ideal ForStatic sites, blogsFull-stack appsSPAsVue full-stack
DeploymentAny CDNVercel optimizedAny static hostAny

Advanced Patterns

Gatsby: Custom Source Plugins

// gatsby-source-custom-api/gatsby-node.js
const fetch = require('node-fetch');
 
exports.sourceNodes = async ({ actions, createNodeId, createContentDigest }) => {
  const { createNode } = actions;
  const response = await fetch('https://api.example.com/products');
  const products = await response.json();
 
  products.forEach((product) => {
    createNode({
      ...product,
      id: createNodeId(`product-${product.id}`),
      parent: null,
      children: [],
      internal: {
        type: 'Product',
        contentDigest: createContentDigest(product),
      },
    });
  });
};

Next.js: API Routes as Backend

// pages/api/products/[id].js
export default async function handler(req, res) {
  const { id } = req.query;
 
  if (req.method === 'GET') {
    const product = await db.products.findById(id);
    if (!product) {
      return res.status(404).json({ error: 'Product not found' });
    }
    return res.status(200).json(product);
  }
 
  if (req.method === 'PUT') {
    const updated = await db.products.update(id, req.body);
    return res.status(200).json(updated);
  }
 
  res.setHeader('Allow', ['GET', 'PUT']);
  res.status(405).end(`Method ${req.method} Not Allowed`);
}

Testing Strategies

Both frameworks require testing at multiple levels, but the approaches differ:

// Gatsby: Testing static queries
import React from 'react';
import { render } from '@testing-library/react';
import { useStaticQuery } from 'gatsby';
import BlogIndex from '../pages/index';
 
jest.mock('gatsby', () => ({
  useStaticQuery: jest.fn(),
  graphql: jest.fn(),
  Link: ({ to, children }) => <a href={to}>{children}</a>,
}));
 
describe('Blog Index', () => {
  beforeEach(() => {
    useStaticQuery.mockReturnValue({
      allMarkdownRemark: {
        edges: [
          {
            node: {
              id: '1',
              excerpt: 'Test excerpt',
              fields: { slug: '/test-post' },
              frontmatter: { title: 'Test Post', date: '2019-10-01' },
            },
          },
        ],
      },
    });
  });
 
  it('renders blog posts', () => {
    const { getByText } = render(<BlogIndex />);
    expect(getByText('Test Post')).toBeInTheDocument();
  });
});
// Next.js: Testing pages with getStaticProps
import { render, screen } from '@testing-library/react';
import Home, { getStaticProps } from '../pages/index';
import { getSortedPostsData } from '../lib/posts';
 
jest.mock('../lib/posts');
 
describe('Home Page', () => {
  it('renders posts from getStaticProps', async () => {
    getSortedPostsData.mockReturnValue([
      { id: 'test', title: 'Test Post', date: '2019-10-01' },
    ]);
 
    const { props } = await getStaticProps();
    render(<Home allPostsData={props.allPostsData} />);
 
    expect(screen.getByText('Test Post')).toBeInTheDocument();
  });
});

Future Outlook (From a 2019 Perspective)

In 2019, both frameworks were evolving rapidly. Key developments on the horizon included:

  • Next.js: The introduction of getStaticProps and getStaticPaths (coming in Next.js 9.3) would bring Gatsby-like static generation capabilities, significantly narrowing the feature gap.
  • Gatsby: Incremental builds and Deferred Static Generation promised to solve the build time problem, making Gatsby viable for larger sites.
  • The rise of hybrid: Both frameworks were moving toward a model where static and dynamic rendering could coexist within a single application.

Conclusion

The Next.js vs Gatsby decision in 2019 ultimately came down to your project's nature and your team's preferences. Here are the key takeaways:

  1. Choose Gatsby if: You're building a content-heavy site (blog, documentation, marketing), your content comes from a CMS, you want the best image optimization out of the box, and you're comfortable with GraphQL.

  2. Choose Next.js if: You're building a web application with dynamic data, you need server-side rendering for SEO or personalization, you want API routes to simplify your architecture, or you need maximum flexibility in rendering strategies.

  3. Consider the ecosystem: Gatsby's plugin ecosystem was more mature for static site features, while Next.js was catching up fast with a more flexible foundation.

  4. Think about scale: Gatsby's build times could become painful for large sites; Next.js's hybrid approach scaled more gracefully.

Both frameworks represented significant improvements over vanilla React development, and either choice was a solid foundation for building modern web experiences. The key was understanding their strengths and choosing the one that best aligned with your specific requirements. In the years that followed, Next.js would gradually absorb many of Gatsby's best ideas while maintaining its architectural flexibility—a trajectory that would ultimately shift the balance decisively in Next.js's favor.