Skip to main content
ASoc
Tutorial

React: Pass a Component as a Prop, the Way This Codebase Does It 35 Times

Every icon on this site is JSX assigned to a data field, not a name looked up inside a component. 35 instances across 5 data files, one helper function pattern.

The ASoc Team9 min read

Pass a component as a prop by writing the JSX where you'd normally write a string, then rendering {prop} on the receiving end — no children, no render-prop function, no extra wrapper. This codebase does exactly that 35 times: every section's icon is a pre-built <svg> handed to a molecule as an ordinary object field, never re-derived inside it.

The short answer

"Passing a component as a prop" means the value of a prop is JSX (or a ReactNode) rather than a string or number — <FeatureCard icon={<svg>...</svg>} /> instead of <FeatureCard iconName="check" />. The receiving component doesn't know or care what produced the element; it just renders {icon} wherever the icon belongs. It's the same mechanism as passing children, just under a name you choose yourself.

Where this codebase actually does it

CLAUDE.md states the rule for src/data: "Files are .tsx because items embed icon/image JSX." Every section's content array is .tsx, not .ts, for exactly this reason — the data can't be plain JSON because some of its fields are React elements.

Fileicon: field typeJSX literals
src/data/features.tsxReactNode14
src/data/heroTech.tsxReactNode7
src/data/footer.tsxReactNode6
src/data/techStack.tsxReactNode5
src/data/plugins.tsxReactNode3 (via a helper, below)
Total35

FeatureCard, the molecule that renders one of those 14 features.tsx entries, never imports an icon library and never switches on a name string:

// src/components/molecules/FeatureCard.tsx
import type { FeatureItem } from "@/data/features";

export default function FeatureCard({ icon, title, description }: FeatureItem) {
  return (
    <div className="rounded-3xl border border-stroke-secondary bg-gray-50 p-1 duration-200 hover:border-primary-200 hover:bg-primary-25 md:p-2">
      <div className="h-full rounded-2xl border border-[#F2F4F7] bg-white p-4 md:p-6">
        <div className="mb-7.5 text-primary">{icon}</div>
        <h3 className="mb-4 text-xl font-semibold text-title-color md:text-2xl lg:text-xl xl:text-2xl">
          {title}
        </h3>
        <p className="text-base !leading-normal text-text-color-secondary">
          {description}
        </p>
      </div>
    </div>
  );
}

{icon} is the entire integration. Whatever icon holds — an inline <svg>, an <img>, eventually even another component instance — renders exactly where it's placed, with zero branching in the molecule. The type signature backs this up: FeatureItem.icon is declared as ReactNode, React's own "anything renderable" type, not string or an enum of known icon names.

FeaturePill — the compact pill variant used elsewhere on the home page — repeats the identical shape from a different data file:

// src/components/molecules/FeaturePill.tsx
import type { FeaturePillItem } from "@/data/features";

export default function FeaturePill({ icon, title }: FeaturePillItem) {
  return (
    <div className="flex items-center gap-4 rounded-3xl border border-stroke-secondary bg-white px-4 py-3 duration-200 hover:border-primary-200 md:px-7.5 md:py-6">
      <div className="flex items-center gap-4">
        <div className="text-primary">{icon}</div>
        <h3 className="text-lg font-semibold text-text-color md:text-xl lg:text-lg xl:text-xl">
          {title}
        </h3>
      </div>
    </div>
  );
}

Two different molecules, two different data files, the same one-line contract: receive a ReactNode, render it, style its container. Neither file needs to know an icon exists as a concept — it just holds a slot.

The function-that-returns-JSX variant

src/data/plugins.tsx does the same thing through one extra layer — a small function that produces the element instead of writing it inline three times:

// src/data/plugins.tsx
const placeholderLogo = (alt: string): ReactNode => (
  <img
    alt={alt}
    decoding="async"
    height="44"
    loading="lazy"
    src="/images/placeholder-logo.svg"
    style={{ color: "transparent" }}
    width="44"
  />
);

export const pluginCards: PluginItem[] = [
  { icon: placeholderLogo("Apex Charts"), title: "Apex Charts", description: "..." },
  { icon: placeholderLogo("Jsvectormap"), title: "Jsvectormap", description: "..." },
  { icon: placeholderLogo("Prettier"), title: "Prettier", description: "..." },
];

placeholderLogo isn't a component — it's a plain function typed to return ReactNode, called three times while the module loads, not three times per render. Each call produces one already-built <img> element that gets frozen into the pluginCards array alongside its title and description. PluginCard, the molecule that consumes it, is just as unaware of placeholderLogo's existence as FeatureCard is of raw JSX literals — it still only sees icon: ReactNode and renders {icon}.

That distinction matters the moment a prop needs to react to something at render time rather than be computed once at import time. A function called during module evaluation can't read a component's state or props — if the eventual design needs the icon's color to change with a hover state that isn't purely CSS, the element has to move into the render path (a real component, or a render-prop function evaluated inside PluginCard) instead of staying a value computed once at the top of the file. Nothing here needs that yet, which is exactly why the simpler shape — a value, not a function-as-prop — is the right one.

Why not a prop-drilled string instead

The alternative most tutorials reach for is an icon name plus a lookup table inside the component:

// what this codebase does NOT do
const ICONS = { check: <CheckIcon />, star: <StarIcon /> };
function FeatureCard({ iconName, title, description }: { iconName: keyof typeof ICONS; ... }) {
  return <div>{ICONS[iconName]}...</div>;
}

That shape forces every consumer of FeatureCard to agree on a shared icon vocabulary, and it forces the molecule to import every icon any caller might ever request — even the ones only techStack.tsx uses. Passing the element itself instead means each data file owns exactly the icons it needs, FeatureCard never imports an icon library at all, and a brand-new one-off SVG for a single feature costs nothing beyond writing it where it's used.

Comparison: value vs. render prop vs. children

PatternLooks likeRe-evaluated on parent re-render?Used in this codebase
Element as a value (this repo)icon: <Svg /> in a data objectNo — built once, held as a reference35 times, src/data/*.tsx
Function-returning-elementicon: placeholderLogo("x")No — called once at module load3 times, plugins.tsx
Render proprender={(state) => <Svg color={state.hue} />}Yes — called by the parent every render0 times
children<Card>{content}</Card>Depends on where content is createdUsed for page-level composition (Container, templates), not icons

The house rule from CLAUDE.md explains the boundary directly: "Molecules never import runtime values from data files (type-only imports OK) — organisms compute and pass props." An icon is exactly that kind of runtime value, so it's computed once, at the data layer, and passed down — never reconstructed inside the molecule that displays it.

Troubleshooting

SymptomCauseFix
TypeScript build fails on a lowercase SVG tag inside a data file<clippath>/<lineargradient> etc. compile in next dev but fail next build's type-checkUse JSX casing — clipPath, linearGradient — per the convention CLAUDE.md documents for every inline SVG in this repo
An icon renders once and never updatesThe element was built by a plain function at import time (like placeholderLogo), so it holds no reference to component stateMove the icon into the component's own render path (a real child component or a render prop) if it ever needs to react to props
A new icon "leaks" into an unrelated bundleAn icon library was imported inside a shared molecule instead of passed in as a propImport the icon only in the data file that needs it; keep the molecule generic over ReactNode
Passing a component type instead of an element throws or renders nothingicon={Svg} (the function reference) was passed where icon={<Svg />} (an already-invoked element) was expectedInvoke it — <Svg /> — before assigning it to the prop; a ReactNode prop wants an element, not a component reference
Prettier keeps re-sorting classNames inside a JSX-as-prop icon differently than expectedprettier-plugin-tailwindcss sorts class order inside any JSX it can see, including icons buried in data filesThis is expected — per CLAUDE.md, class order is irrelevant as long as every class survives

Frequently asked questions

Is passing a component as a prop the same as children? Mechanically yes — both are ReactNode values rendered with {}. The difference is naming and cardinality: children is React's implicit single slot for "whatever's nested inside the tag," while a named prop like icon lets a component accept several independent renderable slots (icon, plus a separate title, description) at once.

Why not just pass an icon name string and look it up inside the component? That works, but it makes the component own a fixed vocabulary of icons it must import, whether or not a given page uses them. Passing the element directly means the data file — the only place that actually needs a specific icon — is the only place that imports it.

Does this pattern cost anything at runtime? No more than any other JSX. A ReactNode held in a data array is just a lightweight React element description; nothing renders until the tree that includes it actually mounts.

When should a function-returning-JSX (like placeholderLogo) become a real component instead? The moment it needs access to component state, context, or per-render props. A function called once during module load — like placeholderLogo here — can't react to anything; if the design later needs the icon's appearance to depend on hover, theme, or another prop, it has to move into the actual render path.

Templates in this post

ASoc Sentinel, ASoc Signal and ASoc Sterling are Next.js + Tailwind landing page templates built on the same FeatureCard/FeaturePill components audited above — each one's feature grid is just a new src/data/*.tsx array of icon-and-copy pairs, no molecule changes required.

Browse the full sets: Next.js landing page templates, Tailwind landing page templates.

Keep reading

Tutorial9 min read

React Images: 26 `<img>` Tags and Not One `import`

1,160 image files, zero imported ones. The import-vs-string-path choice is really a choice about who verifies the path — and what you have to build once the bundler stops.

Read more
Tutorial9 min read

React Protected Routes: What a Server-Rendered Guard Does Differently

React Router's client-side wrapper isn't the only pattern. This codebase's redirect guard runs on the server, plus the open-redirect check most tutorials skip on the ?next= param.

Read more