DEV Community

Cover image for SVG icons in React Server Components: what ships to the client
Rushan
Rushan

Posted on Originally published at geoicons.io

SVG icons in React Server Components: what ships to the client

The App Router flipped the default. Every component you write is a Server Component until something opts it out, and one 'use client' at the top of a file opts out everything below it. That makes a question worth asking before you add an icon library: does importing a single icon pull your page into a client tree?

For a lot of libraries the answer is yes. Nothing breaks when it happens, the page still renders, and you start shipping JavaScript for a static drawing.

GeoIcons icon components carry no 'use client' directive, so they render on the server and arrive as plain markup. Checking any library for this takes one search. Setting it up and confirming the result takes four steps.

// app/page.tsx (no 'use client' anywhere)
import { UnitedStates, France, Japan } from '@geoicons/react/countries';

export default function Page() {
  return (
    <p className="flex items-center gap-4">
      <UnitedStates size={40} aria-label="United States" />
      <France size={40} aria-label="France" />
      <Japan size={40} aria-label="Japan" />
    </p>
  );
}
Enter fullscreen mode Exit fullscreen mode

See it live on geoicons.io →

That page is a Server Component. Next.js generates the SVG markup at build time and serves it as HTML, so no icon code runs in the browser. The route still loads React itself, and the RSC payload repeats the same path data further down the document, which matters when you go counting tags in Step 4.

Key takeaways

  • A 'use client' directive inside an icon library turns every page that imports from it into a client tree. Check for it before you install.
  • GeoIcons icon components have no directive, so you can import and render them inside a Server Component.
  • IconProvider is the one client piece, because it needs state and effects. Icons never read its context, so it can sit in your root layout and nothing else has to move.
  • Search the built client chunks to confirm. If no icon path string lands in one, the library cost you no client JavaScript.

Step 1: Find out whether your icon library is a Client Component

The directive has to be the first statement in a module, so you can settle this with one search over the installed package:

grep -rl "^['\"]use client['\"]" node_modules/@geoicons/react/
Enter fullscreen mode Exit fullscreen mode

For GeoIcons that returns a single file, dist/_license.js, which is the license provider. None of the icon modules appear, because none of them declare the directive.

The ^ anchor earns its place. A directive only counts as the first statement in a module, and the GeoIcons icon source happens to mention use client inside a comment explaining why it avoids one. Drop the anchor and the search matches all 799 icon files while telling you nothing.

Run the same search against your current icon library. If every icon file comes back, importing one icon marks that module as a client boundary, and every component you render underneath it becomes client code too.

You can also open the file. A GeoIcons country component is short enough to read at a glance:

// node_modules/@geoicons/react/countries/base/Nz.tsx (abridged)
import { useId } from 'react';
import type { SVGProps } from 'react';
import { noteIconRender } from '@geoicons/core';

interface Props extends SVGProps<SVGSVGElement> {
  size?: number | string;
  strokeWidth?: number;
}

export const Nz = ({ size = 24, strokeWidth = 1, 'aria-label': ariaLabel, role, ...props }: Props) => {
  const uid = useId();
  noteIconRender();
  return (
    <svg
      viewBox="0 0 24 24"
      width={size}
      height={size}
      stroke="currentColor"
      strokeWidth={strokeWidth}
      fill="none"
      role={ariaLabel ? (role ?? 'img') : role}
      aria-labelledby={ariaLabel ? `${uid}-title` : undefined}
      aria-hidden={ariaLabel ? undefined : true}
      {...props}
    >
      {ariaLabel && <title id={`${uid}-title`}>{ariaLabel}</title>}
      <path d="M7.646 22.656 4.694 21.39a.5.5 0 0 1-.132-.836l5.414-4.75…" />
    </svg>
  );
};
Enter fullscreen mode Exit fullscreen mode

Files are named by ISO alpha-2 code, and the barrel re-exports each one under its full country name as well, which is why you import NewZealand and read Nz.tsx.

Everything in there runs on the server. useId derives an identifier from the component's position in the render tree, which is why it works during server rendering. The icon spends it twice, as the <title> element's id and as the aria-labelledby value on the <svg>, and that pair is what lets a screen reader read the label. Pass no aria-label and the icon sets aria-hidden on itself instead, so a decorative icon stays out of the accessibility tree without you asking. Making SVG icons screen-reader accessible goes further into that behavior.

The noteIconRender() call is a license compliance nudge. GeoIcons writes it as a plain guarded function so it does not need useEffect, because reaching for an effect there would have forced the directive onto all 799 icons.

Step 2: Render icons directly in a Server Component

Once you know the icons are server-safe, import and render them:

// app/countries/page.tsx
import { UnitedStates, Germany, Japan } from '@geoicons/react/countries';

const rows = [
  { Icon: UnitedStates, name: 'United States' },
  { Icon: Germany, name: 'Germany' },
  { Icon: Japan, name: 'Japan' },
];

export default function CountriesPage() {
  return (
    <ul className="divide-y">
      {rows.map(({ Icon, name }) => (
        <li key={name} className="flex items-center gap-3 px-3 py-2">
          <Icon size={24} aria-hidden="true" />
          <span>{name}</span>
        </li>
      ))}
    </ul>
  );
}
Enter fullscreen mode Exit fullscreen mode

See it live on geoicons.io →

The explicit aria-hidden there is belt and braces. Each row already names its country in text, and an icon with no aria-label hides itself, so you could leave it off and get the same result.

Passing components around in an array works here because the page renders on the server and the array never reaches the browser. The icons still tree-shake, because each one is its own named export and the bundler can see which three you referenced. Why an icon library doesn't have to bloat your bundle covers that mechanism.

Icons take their stroke color from CSS through currentColor, so a row in a dark panel gets a light icon without a color prop.

Step 3: Keep the provider at a client boundary

IconProvider is the exception. It reads a license key, verifies it, and stores the result in context. That takes state and effects, so it declares 'use client'. Only that one module is client code, and the icons never read its context to render.

Put it once in the root layout:

// app/layout.tsx
import { IconProvider } from '@geoicons/react';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <IconProvider licenseKey={process.env.NEXT_PUBLIC_GEOICONS_KEY}>
          {children}
        </IconProvider>
      </body>
    </html>
  );
}
Enter fullscreen mode Exit fullscreen mode

A Client Component can render Server Components handed to it as children, so wrapping the tree this way leaves your pages on the server.

If your project is open source under GPLv3, pass the GPL_DECLARATION constant instead of a key. @geoicons/react re-exports it, so you can import it alongside the provider. If you are still evaluating, leave the provider out. Icons render normally either way, and the commercial license setup has the rest.

Step 4: Verify what reached the browser

Do not take the setup on trust. Build the app and read the HTML you serve:

npx next build && npx next start
curl -s http://localhost:3000/countries | grep -o "<svg" | wc -l
Enter fullscreen mode Exit fullscreen mode

Use grep -o here rather than grep -c. Next.js emits its HTML as a handful of very long lines, and -c counts matching lines, so it reports 1 however many icons the page holds. Expect each icon to turn up twice in the count, once in the markup and once in the RSC payload.

Seeing real <path d="M…"> data in that response tells you the server drew the icon. A client-rendered library will still show markup here thanks to SSR, so settle the question in the build output instead. Take a path string you know belongs to an icon and look for it in the client chunks:

grep -rl "M7.646 22.656" .next/static/chunks/ || echo "not in any client chunk"
Enter fullscreen mode Exit fullscreen mode

Nothing found means nothing shipped. A hit does not prove the opposite. It means something in your app renders that icon inside a client boundary, which is a decision you made rather than one the library forced on you. Reading the network tab is the less decisive move, because Next.js shares chunks across routes and one library's cost rarely separates out cleanly.

This site is the working example, and it shows both sides. The related-icons grid at the foot of any icon page is a plain Server Component rendering icons directly, and it arrives in the prerendered HTML with full path data. Run the chunk search against the site and you will still find country paths, because the studio panel higher up the same page is a Client Component that renders icons on demand. Read the grid as the proof rather than the whole page.

When you do need "use client"

Server rendering covers display. When an icon has to respond to a click or a hover, put that behavior in a wrapper:

'use client';

import { useState } from 'react';
import { France } from '@geoicons/react/countries';

export function SelectableCountry() {
  const [selected, setSelected] = useState(false);
  return (
    <button
      onClick={() => setSelected((s) => !s)}
      className={selected ? 'text-blue-600' : 'text-gray-500'}
    >
      <France size={32} aria-label="France" />
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

The icon did not change. It is the same component from the same import, rendering inside a client boundary this time. The icons work on both sides, so you place the boundary where your interface needs it.

Do GeoIcons work in React Server Components?

Yes. The icon components carry no 'use client' directive, so they render inside Server Components and arrive as HTML with no icon code running in the browser. Only IconProvider, the license wrapper, is a Client Component, and the icons never read its context to render.

Why can an icon component call useId and still be a Server Component?

useId derives an identifier from the component's position in the render tree rather than from state or effects, which is why it works during server rendering. The icon uses it as the title element's id and as the aria-labelledby value on the svg, which is the pair a screen reader needs to read the label you pass as aria-label.

How do I check whether my current icon library forces a client boundary?

Search the installed package for a use client directive anchored to the start of a line, for example with grep -rl over node_modules for your icon package. Anchor it: a directive only counts as the first statement in a module, and an unanchored search will also match the string inside comments. If the icon modules come back in the results, importing an icon marks that module as a client boundary and everything rendered underneath it becomes client code.

Does wrapping my app in IconProvider make every page a Client Component?

No. A Client Component can render Server Components handed to it as children, so a provider in the root layout leaves the pages beneath it on the server. Only the provider module itself is client code.

Can I use the same icon in both a Server Component and a Client Component?

Yes, and it is the same import either way. Render it on the server for display, or render it inside a component marked 'use client' when it needs a click handler, hover state, or animation.

Where this leaves you

Icons are static artwork. In an App Router app they should cost you markup and nothing more, and whether they do comes down to one directive in the library you picked.

Check for it before you install. If the icons are server-safe, render them directly, keep any provider at the root, and search the client chunks to confirm.

The full prop list lives in the React API reference, and adding country icons to a React app covers sizing, color, and accessibility from the start.

Top comments (0)