Shaving 140 kB Off a Next.js Bundle: Lazy-Load Portable Text
By Nayan Kyada · · 6 min read
Part of The Sanity + Next.js Guide
Most Sanity projects ship the entire @portabletext/react serializer tree to the client, even when only a homepage hero uses rich text. On a recent agency project, a single blog post detail page loaded 140 kB of JavaScript just to render headings, links, and a custom YouTube embed block. First Contentful Paint sat at 1.8 s on 3G. The client wanted sub-1-second FCP and a Lighthouse performance score above 95.
The Problem: Portable Text Serializers Are Heavy
Portable Text is Sanity's block content format. You define custom serializers for marks, blocks, and inline objects. The official React package works beautifully, but it bundles every serializer—even unused ones—into your client JavaScript if you import it in a client component.
In my case, the schema included a content field with headings, lists, links, images, and a custom youtubeEmbed block. The serializer map looked like this:
// app/blog/[slug]/page.tsx (initial, bad)
import { PortableText } from '@portabletext/react';
import { YouTubeEmbed } from '@/components/YouTubeEmbed';
import { SanityImage } from '@/components/SanityImage';
const components = {
types: {
image: SanityImage,
youtubeEmbed: YouTubeEmbed,
},
marks: {
link: ({ value, children }: any) => (
<a href={value.href} className="underline">{children}</a>
),
},
};
export default async function BlogPost({ params }: { params: { slug: string } }) {
const post = await sanityFetch({ query: POST_QUERY, params });
return (
<article>
<h1>{post.title}</h1>
<PortableText value={post.content} components={components} />
</article>
);
}This page was a server component, but PortableText and my custom serializers pulled in client-side dependencies: react-player for YouTube embeds, next/image (already tree-shaken, but still), and the full Portable Text runtime. Vercel's bundle analyzer showed 142 kB uncompressed in the client chunk.
Step One: Move Portable Text Into a Client Boundary
Next.js App Router lets you isolate client JavaScript. I extracted the PortableText call into a separate client component and kept the data-fetching server component lean.
// app/blog/[slug]/PortableTextRenderer.tsx
'use client';
import { PortableText } from '@portabletext/react';
import dynamic from 'next/dynamic';
const YouTubeEmbed = dynamic(() => import('@/components/YouTubeEmbed'), {
ssr: false,
});
const SanityImage = dynamic(() => import('@/components/SanityImage'));
const components = {
types: {
image: SanityImage,
youtubeEmbed: YouTubeEmbed,
},
marks: {
link: ({ value, children }: any) => (
<a href={value.href} className="underline">{children}</a>
),
},
};
export function PortableTextRenderer({ value }: { value: any }) {
return <PortableText value={value} components={components} />;
}Now the server component imports only the client boundary:
// app/blog/[slug]/page.tsx
import { PortableTextRenderer } from './PortableTextRenderer';
export default async function BlogPost({ params }: { params: { slug: string } }) {
const post = await sanityFetch({ query: POST_QUERY, params });
return (
<article>
<h1>{post.title}</h1>
<PortableTextRenderer value={post.content} />
</article>
);
}This alone saved 18 kB by letting Next.js code-split the renderer into a separate chunk loaded only when the user navigates to a blog post.
Step Two: Lazy-Load the Entire Renderer Below the Fold
Most blog posts have a hero, metadata, and a share bar before the article body. The Portable Text block starts 600–800 pixels down the page. I wrapped the renderer in a dynamic() import with ssr: false so it hydrates only after the initial paint.
// app/blog/[slug]/page.tsx
import dynamic from 'next/dynamic';
const PortableTextRenderer = dynamic(
() => import('./PortableTextRenderer').then((mod) => mod.PortableTextRenderer),
{ ssr: false }
);
export default async function BlogPost({ params }: { params: { slug: string } }) {
const post = await sanityFetch({ query: POST_QUERY, params });
return (
<article>
<header className="mb-12">
<h1>{post.title}</h1>
<time>{post.publishedAt}</time>
</header>
<PortableTextRenderer value={post.content} />
</article>
);
}Bundle size dropped to 74 kB. FCP improved to 0.9 s on 3G. The trade-off: users see a blank space for 100–200 ms while the chunk loads. I added a skeleton loader inside a <Suspense> boundary to smooth the transition.
A caveat worth stating plainly: ssr: false means the article body is not in the server-rendered HTML at all — it only appears after the client chunk loads and hydrates. For a blog whose whole point is organic search visibility, that is a real risk, not just a UX trade-off. Googlebot generally does execute JavaScript, but rendering happens in a second wave separate from initial crawl and indexing, and other crawlers (some AI answer engines, link-preview bots, text-only scrapers) may not execute JS at all — meaning your actual article text may be invisible to them. I use this pattern selectively: below-the-fold supplementary content, not the primary body copy that's the reason the page ranks. Step Three's server-rendered plain-HTML path is the safer default for most posts; reserve full client-side lazy-loading for pages where SEO visibility of that specific content genuinely doesn't matter.
Step Three: Render Plain Blocks on the Server
For posts with no custom blocks—just headings, paragraphs, and links—I wrote a lightweight server-side serializer that outputs plain HTML. I check the Portable Text array for custom types in the RSC, then conditionally render.
// lib/hasCustomBlocks.ts
export function hasCustomBlocks(value: any[]): boolean {
return value.some(
(block) => block._type === 'youtubeEmbed' || block._type === 'image'
);
}
// app/blog/[slug]/page.tsx
import { hasCustomBlocks } from '@/lib/hasCustomBlocks';
import { renderPlainPortableText } from '@/lib/renderPlainPortableText';
export default async function BlogPost({ params }: { params: { slug: string } }) {
const post = await sanityFetch({ query: POST_QUERY, params });
const usesCustomBlocks = hasCustomBlocks(post.content);
return (
<article>
<h1>{post.title}</h1>
{usesCustomBlocks ? (
<PortableTextRenderer value={post.content} />
) : (
<div dangerouslySetInnerHTML={{ __html: renderPlainPortableText(post.content) }} />
)}
</article>
);
}The renderPlainPortableText function is a 40-line pure function that maps blocks to HTML strings. No client JavaScript needed. Posts without embeds now ship 12 kB of hydration JS instead of 74 kB.
Results and Trade-Offs
After all three steps, the median blog post bundle dropped from 142 kB to 12 kB. Posts with embeds load 74 kB. Lighthouse performance score went from 82 to 97. LCP improved by 400 ms.
The downside: increased complexity. I now maintain two rendering paths and a custom server-side serializer. For teams that frequently add new Portable Text block types, this can become a maintenance burden. But for marketing sites with stable content schemas, the performance gain is worth it.
If you're shipping Sanity Portable Text in a Next.js app and your client bundles are over 100 kB, audit your serializers. Move them into client boundaries, lazy-load below the fold, and consider server-rendering simple blocks. The gains compound when you serve millions of page views.
Frequently asked questions
01Why does @portabletext/react bloat my Next.js client bundle?
Importing `PortableText` and its serializer map inside a client component pulls every serializer into the client JavaScript bundle, even ones a given page never uses — a custom YouTube embed component, image handling, everything. Isolating the renderer into its own client boundary lets Next.js code-split it into a chunk loaded only when that route is visited, rather than shipping it on every page.
02Is it safe to lazy-load Portable Text content with ssr: false for SEO?
Not for the primary body content of a page you want to rank. `ssr: false` means that content isn't in the server-rendered HTML at all — it only appears after the client chunk hydrates, which is invisible to crawlers that don't execute JavaScript and happens in a separate rendering pass even for Googlebot. Reserve `ssr: false` for below-the-fold supplementary content; render the primary article body server-side, using a plain-HTML server serializer for posts with no custom blocks if bundle size is the concern.