Skip to main content
ASoc
Tutorial

React Lazy Loading: This Codebase Uses Zero React.lazy() Calls

This codebase has zero React.lazy() calls. What it actually lazy-loads — an explicit per-post import map and a conditional SDK import — and why that distinction matters.

The ASoc Team7 min read

React.lazy() splits a component out of the main bundle and loads it on demand behind a <Suspense> boundary. This codebase has never called it — grep src/ and the count is zero. That's not an oversight: Server Components already mean most components ship no client JavaScript at all, so there's nothing to split. The lazy loading that actually matters here happens one layer down, at the module level, in two places worth reading directly.

Three things people call "lazy loading" in React

PatternWhat's deferredWhere it lives here
React.lazy() + <Suspense>A component's code, loaded on first renderNowhere in this codebase — shown below as a written example only
Route-level streaming (loading.tsx, a <Suspense> around a server fetch)A slow data fetch, not client codeAlready covered in the skeleton-loader post — a different mechanism, often confused with the one above
A conditional import() at module scopeAn entire dependency, loaded only when a runtime check says it's neededsrc/lib/blog.ts (below) and src/lib/supabase/lazyClient.ts (linked, not re-derived)

All three defer something, and all three get called "lazy loading" interchangeably online — which is exactly why the SERP for this term is wall-to-wall React.lazy() tutorials that never mention the other two. This codebase's actual lazy-loading decisions live in rows two and three.

The pattern nobody's written about: an explicit per-route dynamic-import map

Every one of this blog's 93 posts is a compiled MDX file, and the router needs to load exactly one of them per page view — not all 93. The obvious approach is a wildcard dynamic import keyed by the URL slug:

// what NOT to do
const mod = await import(`../content/blog/${slug}.mdx`);

This codebase does it differently, on purpose:

// src/lib/blog.ts
const postLoaders: Record<string, () => Promise<{ default: ComponentType }>> = {
  "gumroad-alternative": () => import("@/content/blog/gumroad-alternative.mdx"),
  "nextjs-infinite-scroll": () =>
    import("@/content/blog/nextjs-infinite-scroll.mdx"),
  // ...one entry per published post, 93 as of this build
};

export async function getPostContent(
  slug: string,
): Promise<ComponentType | undefined> {
  const loader = postLoaders[slug];
  if (!loader) return undefined;
  const mod = await loader();
  return mod.default;
}

Both versions are lazy — neither eagerly bundles all 93 MDX files into one chunk, and both resolve to a per-post dynamic import at request time. The difference is what happens when something's wrong. The wildcard version hands the bundler a glob: every file under that directory becomes reachable from that one call site, a typo in slug fails silently at runtime with undefined, and nothing here type-checks against the actual file list. The explicit map fails differently — a slug missing its own line is a next build failure the moment generateStaticParams calls it, and src/data/__tests__/blog.test.ts turns a forgotten entry into a failing npm test before it ever reaches a build. The laziness is identical; the failure mode a typo produces is not, and that's the actual decision buried inside "should I lazy-load this."

Confirming it's actually lazy, not just written that way

Explicit or wildcard, a dynamic import() only pays off if the bundler actually splits the target into its own chunk instead of pulling it into whatever imports the map. A production build of this site (.next/server/chunks/) settles it: each compiled post lands in its own file, named after the source module —

src_content_blog_gumroad-alternative_mdx_tsx_09hv-i9._.js
src_content_blog_react-sidebar_mdx_tsx_11jkzok._.js
src_content_blog_tailwind-data-table_mdx_tsx_04qae75._.js

80 separate chunk files for this build's 93 posts — some share a chunk where Turbopack found overlapping dependencies, but there is no single bundle containing all of them. Requesting /blog/gumroad-alternative loads that post's ~21 KB chunk; it does not load the other 92. That's the property postLoaders exists to guarantee, and it's checkable in any Next.js project the same way: build, then look at what actually landed in .next/server/chunks/ rather than trusting that a dynamic import() did what it says.

What React.lazy() looks like, written for comparison

Since this repo has nothing to show for the canonical case, here's the pattern the SERP for this keyword universally teaches — a heavy, rarely-used component split out of a page that doesn't need it on first paint:

import { lazy, Suspense } from "react";

const ScreenshotCompareTool = lazy(() => import("./ScreenshotCompareTool"));

export function ProductPage() {
  return (
    <Suspense fallback={<div className="animate-pulse h-64 rounded-lg bg-gray-100" />}>
      <ScreenshotCompareTool />
    </Suspense>
  );
}

lazy() takes a function returning a dynamic import() and hands back a component the renderer can suspend on; Suspense supplies what to show while that import resolves. This is the right tool when a specific component is both heavy and conditionally needed — a rich text editor behind an "edit" click, a chart library behind a tab that's not the default. Nothing on this site fits that shape today: the closest candidate, TemplateGallery, mounts every slide up front deliberately, because a lazily-mounted slide would be invisible to a crawler.

The module that actually matters: an SDK, not a component

The one place this codebase defers something because of its weight is src/lib/supabase/lazyClient.ts — not a UI component, but the entire @supabase/ssr client. Covered in full elsewhere (the cookie-probe short-circuit, the exact byte count, the effect-scoped call sites), so it's linked here rather than re-quoted: the summary relevant to this post is that it's a conditional dynamic import() at module scope, gated on a synchronous check (hasAuthCookie()) that runs before the import fires at all. That's a third variant React.lazy() doesn't cover — lazy() always defers a component's code; this defers a dependency, and only imports it when a cheap synchronous check says there's a reason to.

Mistakes and how they show up

MistakeSymptomFix
Reaching for React.lazy() when the real cost is a dependency, not a componentThe component code was already small; the bundle didn't shrinkLook at what's actually heavy — often a library import inside a small component, not the component itself
A wildcard dynamic import for a per-slug/per-route mapA typo resolves to undefined at runtime, not a build failureWrite the map explicitly, one entry per known key, like postLoaders
Wrapping a <Suspense> around a Client Component that fetches on mountLoading state exists client-side for data that could have been fetched on the serverMove the fetch to a Server Component and use route-level streaming instead — see the skeleton-loader post
Forgetting Suspense needs a fallback in the tree above the lazy component, not inside itThe whole subtree throws instead of showing a loading stateThe <Suspense> boundary has to be an ancestor of the lazy-loaded component, never a sibling
Assuming a conditional import() needs React.lazy() to workExtra Suspense machinery for something that's really just an await import() behind an ifA plain async function with a dynamic import is enough when nothing is rendering a fallback UI for it

Frequently asked questions

Why doesn't this codebase use React.lazy() anywhere? Most of its 91 components are Server Components that ship no client JavaScript regardless of lazy loading — the census is published here — and the handful of Client Components (a preview modal, a wishlist toggle, a mobile menu) are small enough that splitting them out wouldn't measurably change what ships.

Is loading.tsx in the App Router the same thing as React.lazy()? No, and conflating them is the single most common mistake in this space. loading.tsx and a <Suspense> around a server-fetched segment defer rendering while data loads; React.lazy() defers loading a component's code. You can use either without the other.

When should I actually reach for React.lazy()? When a specific component is both heavy (a large library dependency) and conditionally rendered (behind a tab, a modal, a feature flag) — not by default, and not for every component below the fold. Server Components already solve the "don't ship it if it's not needed" problem for anything that doesn't need client interactivity at all.

What's the practical difference between React.lazy() and a plain dynamic import()? React.lazy() is specifically for lazy-loading a component inside a <Suspense> tree — it expects the module's default export to be a component and integrates with React's render/suspend cycle. A plain await import(), like postLoaders uses, is the right tool when you're loading a module for its value (compiled MDX, a client SDK) rather than mounting it as a component in the render tree.

Templates in this post

ASoc Signal is an AI voice-and-image platform site with a six-tool image studio — the kind of feature set where a rarely-used tool (say, an in-browser upscaling preview) would be a legitimate React.lazy() candidate if this codebase had one. ASoc Sterling is a wealth-management site with a balance-dashboard preview and an advisor-reviewed positioning. ASoc Surge is a neural-network startup site with tabbed capabilities — another natural fit for splitting a non-default tab's content, done here with plain conditional rendering rather than a dynamic import, because the tab content is light enough not to need it.

Browse the full sets: Next.js landing page templates, Tailwind landing page templates. For the deep dive on this codebase's one real dependency-weight defer, React Server Components vs. Client Components; for the difference between a loading state and a lazy-loaded component, React skeleton loaders.

Keep reading

Tutorial8 min read

React Modal, Zero Dependencies: The Iframe Focus Bug We Fixed

A real accessible React modal with zero dependencies — focus trap, ARIA dialog role, restore-on-close, and the iframe-focus-escape bug most modal libraries never mention.

Read more
Tutorial8 min read

React Multi-Step Forms: 1 of 5 Form Components Here Needs One

Most tutorials assume a step-index useState pattern by default. This codebase's one real example is two states confirming an irreversible choice, not more fields.

Read more