Cutting Next.js Bundle Size: Server-Render and Lazy-Load Portable Text

By Nayan Kyada · · 6 min read

Part of The Sanity + Next.js Guide

Many Sanity projects end up shipping the entire @portabletext/react renderer and serializer tree to the client — usually without anyone deciding to. A blog post page that only needs headings, links, and one YouTube embed can easily load 100 kB+ of JavaScript just to render rich text. The fix in the App Router is mostly about where Portable Text renders, not about clever lazy-loading.

The Problem: Portable Text in a Client Component

Portable Text is Sanity's block content format. You define custom serializers for marks, blocks, and inline objects. The official React package works well in both Server and Client Components — the bloat appears when it renders under a 'use client' boundary. That happens more often than you'd think: an article body component gets 'use client' because it needs a copy-link button or a scroll tracker, and suddenly the renderer and every serializer it imports ship to the browser.

A typical schema has a content field with headings, lists, links, images, and a custom youtubeEmbed block. The problematic version looks like this:

// app/blog/[slug]/ArticleBody.tsx (bad: everything ships to the client)
'use client';
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 function ArticleBody({ value }: { value: any }) {
  return <PortableText value={value} components={components} />;
}

Because of the 'use client' directive, the Portable Text runtime, every serializer, and their dependencies — for example react-player for YouTube embeds — end up in the client chunk for this route. The bundle analyzer shows it as one large block.

Step One: Render Portable Text in a Server Component

Remove 'use client' from the renderer and let the page (a Server Component) render Portable Text directly. Static blocks — headings, paragraphs, lists, links — become plain HTML with no client JavaScript at all. Move any interactivity (the copy-link button, the scroll tracker) into small client components of their own.

// app/blog/[slug]/PortableTextRenderer.tsx (no 'use client' — a Server Component)
import { PortableText } from '@portabletext/react';
import { YouTubeEmbed } from '@/components/YouTubeEmbed'; // a client component
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 function PortableTextRenderer({ value }: { value: any }) {
  return <PortableText value={value} components={components} />;
}
// app/blog/[slug]/page.tsx
import { PortableTextRenderer } from './PortableTextRenderer';
 
export default async function BlogPost({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  const post = await sanityFetch({ query: POST_QUERY, params: { slug } });
  return (
    <article>
      <h1>{post.title}</h1>
      <PortableTextRenderer value={post.content} />
    </article>
  );
}

Now the only Portable Text–related JavaScript that reaches the browser is whatever the client-component serializers need — in this case, the YouTube embed.

Step Two: Lazy-Load the Heavy Interactive Blocks

The YouTube player is the expensive part, and it's usually below the fold. Make YouTubeEmbed a small client component that renders a lightweight placeholder (thumbnail + play button) and only loads the player library when needed. next/dynamic with ssr: false is allowed only inside Client Components, so the dynamic import lives there:

// components/YouTubeEmbed.tsx
'use client';
import dynamic from 'next/dynamic';
 
// VideoPlayer wraps your player library of choice (react-player, lite-youtube-embed, …)
const Player = dynamic(() => import('@/components/VideoPlayer'), {
  ssr: false,
  loading: () => <div className="aspect-video animate-pulse rounded-md bg-zinc-100" />,
});
 
export function YouTubeEmbed({ value }: { value: { url: string } }) {
  return (
    <div className="aspect-video">
      <Player url={value.url} />
    </div>
  );
}

The player code now loads in its own chunk, and only on posts that actually contain a video. Posts without embeds don't pay for it at all.

A caveat worth stating plainly: ssr: false means that component's output is not in the server-rendered HTML at all — it only appears after the client chunk loads and hydrates. That's fine for a video player, but don't apply it to the article body itself. For a blog whose whole point is organic search visibility, client-only body text 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. Reserve ssr: false for below-the-fold supplementary content and interactive widgets, not the primary body copy that's the reason the page ranks.

Step Three: Keep Plain Blocks on the Server

With Step One in place, posts with no custom blocks — just headings, paragraphs, and links — already render as plain server HTML. You don't need a separate hand-written serializer or dangerouslySetInnerHTML; PortableText in a Server Component does exactly that. If you need an HTML string outside React (an RSS feed, an email), @portabletext/to-html covers that case.

The thing to watch is regressions: one 'use client' added high in the tree (for example on a layout or article wrapper) pulls the whole renderer back into the client bundle. Keep client components at the leaves.

Results and Trade-Offs

How much you save depends on what your serializers import, so measure it: run the bundle analyzer before and after, and compare first-load JS for a post with and without embeds. On a typical blog, moving Portable Text out of a client boundary removes the renderer and all static serializers from the client bundle entirely, and lazy-loading the video player takes the largest single dependency off posts that don't need it.

The downside is discipline rather than complexity: you have to keep interactive serializers as small, isolated client components and resist adding 'use client' to wrapper components. For marketing sites with stable content schemas, that's an easy rule to keep.

If you're shipping Sanity Portable Text in a Next.js app and your client bundles are over 100 kB, audit where your serializers render. Keep Portable Text in Server Components, lazy-load the heavy interactive blocks, and keep the primary body copy in the server HTML. The gains compound when you serve millions of page views.

Frequently asked questions

Why does @portabletext/react bloat my Next.js client bundle?

Only when it renders inside a client component. Importing `PortableText` and its serializer map under a `'use client'` boundary pulls the renderer and every serializer into the client JavaScript bundle, even ones a given page never uses — a custom YouTube embed component, image handling, everything. Render `PortableText` in a Server Component instead: the static blocks become plain HTML with zero client JS, and only serializers that are themselves client components (like an interactive video player) ship JavaScript.

Is 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 in a Server Component, which keeps it in the HTML and out of the client bundle.