Skip to content
HomePagesHomePages template kit
Menu

template-kit/size-island-bundle

The template-kit/size-island-bundle authoring rule — what it enforces, why, and how to fix it.

Each island’s browser bundle must be ≤ 50 KB gzip. The budget is not author-overridable, and it applies per island, not per section.

An island — a component whose file opens with the "use client" directive (see Server vs client) — ships to the browser as its own bundle, separate from the section’s server-rendered HTML. Each island is bundled with esbuild and minified, with React treated as external (the page’s runtime chunk supplies it once, shared by every island on the page, and is not counted against any one of them). The gzipped result must be 50 KB or less.

A section can have more than one island. The budget is checked per island: a section with three islands each at 40 KB passes; a section with one island at 60 KB fails, even though the same section’s other files are small.

The budget is visible in template-kit check’s configuration and is not author-overridable. A template workspace cannot raise it, and there is no per-island exemption.

An island is the part of the page that has to work on a phone before the visitor does anything — a carousel, a filter, a form. Every one of its bytes is downloaded, parsed, and executed on the client, on top of whatever the page’s shared runtime already costs. Unlike the section’s server-rendered markup, which the browser only has to paint, an island’s code has to run.

The number is set to be generous, so it does not fight ordinary authoring — a real interactive widget fits comfortably inside it. You hit the budget when something runaway gets pulled into the client bundle:

  • a whole UI or icon library imported for one component, which drags every export into the bundle so the island can use a handful of them;
  • a heavy dependency that belongs on the server, ported into the island because it was easiest to reach for where the code was being written, not because the browser needs it.

That is the shape of essentially every violation: not an island that grew, but a single import that was more expensive than it looked.

Find the import that is not paying for itself, or narrow what ships to the browser at all.

  • Import named symbols, never a namespace. import { ArrowRight } from "…" is tree-shakeable; import * as Icons from "…" is not — a namespace object has to contain everything, so nothing can be dropped.
  • Prefer the package’s narrow entry if it offers one, instead of its default “everything” bundle.
  • Move what doesn’t need to be interactive out of the island. If only a small part of a component actually needs client-side behavior — an expand/collapse toggle inside an otherwise static block — split the interactive part into its own small island and leave the rest server-rendered. Server-rendered markup costs nothing against this budget.
  • Hand-write the residue. If you use one function out of a large utility package, copy the function instead of importing the package for it.
sections/gallery/Carousel.tsx
"use client";
import * as Icons from "lucide-react"; // ← the whole set: every icon, in this island's bundle
import { useState } from "react";
export default function Carousel({ slides }: { slides: { id: string; url: string }[] }) {
const [index, setIndex] = useState(0);
return (
<div>
<img src={slides[index]?.url} alt="" />
<button onClick={() => setIndex((i) => (i - 1 + slides.length) % slides.length)}>
<Icons.ChevronLeft aria-hidden />
</button>
<button onClick={() => setIndex((i) => (i + 1) % slides.length)}>
<Icons.ChevronRight aria-hidden />
</button>
</div>
);
}
sections/gallery/Carousel.tsx
"use client";
import { ChevronLeft, ChevronRight } from "lucide-react"; // ← two icons, and the bundler can drop the rest
import { useState } from "react";
export default function Carousel({ slides }: { slides: { id: string; url: string }[] }) {
const [index, setIndex] = useState(0);
return (
<div>
<img src={slides[index]?.url} alt="" />
<button onClick={() => setIndex((i) => (i - 1 + slides.length) % slides.length)}>
<ChevronLeft aria-hidden />
</button>
<button onClick={() => setIndex((i) => (i + 1) % slides.length)}>
<ChevronRight aria-hidden />
</button>
</div>
);
}