npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@chiselandco/nexus

v3.4.3

Published

Self-contained project portfolio components for Next.js App Router. Includes ProjectPortfolio, ProjectPortfolioClient, ProjectDetail, SimilarProjects, ProjectMenu, ProjectMenuClient, GalleryCarousel, and FilterSidebar. Pass a clientSlug and apiBase — done

Downloads

805

Readme

@chiselandco/nexus

Self-contained project portfolio components for Next.js App Router. Pass a clientSlug, apiBase, and apiKey — each component fetches, caches, and renders everything it needs with no client-side waterfall requests.

Version: 3.4.3


Requirements

  • Next.js 13+ (App Router)
  • React 18+

No other dependencies required.


Installation

npm install @chiselandco/nexus

Quick Start

The most common full setup — a filterable projects grid, a detail page with similar projects, and a megamenu in the nav.

// app/projects/page.tsx
import { ProjectPortfolio } from "@chiselandco/nexus"

export default async function ProjectsPage({
  searchParams,
}: {
  searchParams: Promise<Record<string, string | string[] | undefined>>
}) {
  return (
    <ProjectPortfolio
      clientSlug="your-client-slug"
      apiBase="https://your-api.com"
      apiKey={process.env.YOUR_CLIENT_API_KEY!}
      basePath="/projects"
      searchParams={await searchParams}
    />
  )
}
// app/projects/[slug]/page.tsx
import { ProjectDetail, SimilarProjects } from "@chiselandco/nexus"

export default async function ProjectPage({
  params,
}: {
  params: Promise<{ slug: string }>
}) {
  const { slug } = await params
  const apiKey = process.env.YOUR_CLIENT_API_KEY!

  return (
    <>
      <ProjectDetail
        slug={slug}
        clientSlug="your-client-slug"
        apiBase="https://your-api.com"
        apiKey={apiKey}
        backPath="/projects"
        backLabel="All Projects"
      />
      <SimilarProjects
        excludeSlug={slug}
        clientSlug="your-client-slug"
        apiBase="https://your-api.com"
        apiKey={apiKey}
        basePath="/projects"
      />
    </>
  )
}
// app/api/chisel-menu/route.ts
import { createMenuHandler } from "@chiselandco/nexus"

export const GET = createMenuHandler({
  clientSlug: "your-client-slug",
  apiBase: "https://your-api.com",
  apiKey: process.env.YOUR_CLIENT_API_KEY!,
})
// components/Nav.tsx
"use client"
import { ProjectMenuClient } from "@chiselandco/nexus"

export function Nav() {
  return (
    <nav>
      <ProjectMenuClient
        dataUrl="/api/chisel-menu"
        basePath="/projects"
        viewAllPath="/projects"
      />
    </nav>
  )
}

Components

FilterSidebar

Client component ("use client"). Renders an "Advanced Filters" trigger that opens a right-side drawer with one section per filterable field. Pills are solid black when active and outlined when inactive. Filter state is written to URL params so filtered views are shareable and survive page refresh.

Use alongside ProjectPortfolio when you want user-driven filtering — place it wherever suits your layout and pass the same searchParams to ProjectPortfolio.

import { FilterSidebar } from "@chiselandco/nexus"

<FilterSidebar
  schema={schema}
  filterKeys={["application", "systems", "material"]}
  triggerLabel="Advanced Filters"
/>

| Prop | Type | Required | Default | Description | |---|---|---|---|---| | schema | CustomFieldSchema[] | Yes | — | Field schema from the API — only select and multi-select fields with options are used | | filterKeys | string[] | No | All eligible fields | Ordered list of field keys to show in the drawer | | triggerLabel | string | No | "Advanced Filters" | Label for the trigger link | | font | string | No | "inherit" | Font family string |


ProjectDetail

Server component. Fetches a single project by slug and renders a hero image, a stats bar, a project overview section with description and specs sidebar, and a GalleryCarousel with filterable media tag pills.

// app/projects/[slug]/page.tsx
import { ProjectDetail } from "@chiselandco/nexus"

export default async function ProjectPage({
  params,
}: {
  params: Promise<{ slug: string }>
}) {
  const { slug } = await params
  return (
    <ProjectDetail
      slug={slug}
      clientSlug="your-client-slug"
      apiBase="https://your-api.com"
      apiKey={process.env.YOUR_CLIENT_API_KEY!}
      backPath="/projects"
      backLabel="All Projects"
    />
  )
}

Field placement (fieldPlacement)

A field's schema display_position describes what a field is, not where it should render. Layout on the detail page is a presentation decision owned by ProjectDetail, and it is overridable per-field by the app via the optional fieldPlacement prop.

Each field resolves to one of three regions on the detail page:

| Placement | Where it renders | How it renders | |---|---|---| | "stats" | Stats bar below the hero | Key/value fact (label = field name) | | "sidebar" | Specs sidebar in "Project Overview" | Chip list (label = field name) | | "hidden" | Not rendered | — |

The hero badge is a separate slot, always driven by the badge_overlay field — so a field can be the badge and be placed in "stats" (e.g. a location shown as both the badge and the first stat).

Resolution order per field:

  1. An explicit entry in the fieldPlacement prop (keyed by the field's schema key), if provided.
  2. Otherwise a sensible default derived from display_position: metadata"stats", tags"sidebar", and badge_overlay/hidden/unset → "hidden".

Because of the fallback, clients that pass no fieldPlacement behave exactly as before — no per-client change is required unless you want a custom arrangement.

<ProjectDetail
  clientSlug="hollaender"
  apiBase={API_BASE}
  apiKey={apiKey}
  fieldPlacement={{
    location: "stats",      // still the hero badge, and also shown as the first stat
    application: "stats",
    system: "sidebar",      // group the full product spec together
    infill: "sidebar",
    material: "sidebar",
    finish: "sidebar",
    side: "hidden",         // internal routing tag, never customer-facing
  }}
/>

Additional rules:

  • A field of type: "location" renders as a combined city, state string; when placed in "stats" it is surfaced as the first stat. It also appears in the hero subtitle.
  • Field labels always come from the schema field's name — the label is never hardcoded.
  • Multi-select and array values are comma-joined (stats bar) or rendered as individual chips (sidebar). Option slugs are resolved to their human labels, and archived options are filtered out.
  • The badge_overlay field renders as the hero badge. For a multi-select badge the first option is shown; for a single-value badge (e.g. a text location like "Mason, OH") the full value is shown verbatim — it is not comma-split, so multi-part values keep every part.

Media enrichment

ProjectDetail automatically fetches custom_field_values from the list endpoint (where they are available) and merges them onto the single-project media items before passing them to GalleryCarousel. No extra work is needed.

| Prop | Type | Required | Default | Description | |---|---|---|---|---| | slug | string | Yes* | — | The project slug to load | | projectSlug | string | Yes* | — | Alias for slug — either one is accepted | | clientSlug | string | Yes | — | The client slug that owns this project | | apiBase | string | Yes | — | Base URL of the projects API | | apiKey | string | Yes | — | Client API key — always pass via environment variable, never hardcode | | backPath | string | No | "/projects" | Path for the back navigation link | | backLabel | string | No | "All Projects" | Label for the back navigation link | | revalidate | number | No | 86400 | Cache revalidation period in seconds | | noCache | boolean | No | false | Sets cache: "no-store" — useful during development |


GalleryCarousel

Client component ("use client"). Image carousel with previous/next arrows, a counter badge, a scrollable thumbnail strip, URL-synced image filters, and automatic media tag pills. Used internally by ProjectDetail but can be used standalone.

"use client"
import { GalleryCarousel } from "@chiselandco/nexus"

export function ProjectGallery({ media, schema, title }) {
  return (
    <GalleryCarousel
      images={media}
      projectTitle={title}
      schema={schema}
    />
  )
}

Media tag pills

When a media item has custom_field_values set, GalleryCarousel renders frosted-glass pills in the bottom-left corner of the active image. Each pill shows the field name and resolved value — e.g. System: Speed-Rail with Mesh Infill, Finish: Black Anodized. Pills update as the user navigates between images.

Image filtering

When images have custom_field_values, a filter bar appears above the gallery. Each field that appears on at least one image is shown as a row of pill buttons. Selecting a pill narrows both the main image and the thumbnail strip to only matching images. Multiple fields can be filtered simultaneously (AND logic). Filter state is written to the URL so filtered views are shareable:

/projects/jacob-javits?filter[system]=Structural Glass&filter[finish]=Black Anodized

| Prop | Type | Required | Default | Description | |---|---|---|---|---| | images | Media[] | Yes | — | Array of media objects from the projects API | | projectTitle | string | Yes | — | Used as the alt text fallback for the main image | | schema | CustomFieldSchema[] | No | [] | Client custom fields schema — used to resolve slug values to labels for pills and filter options |


SimilarProjects

Server component. Fetches all projects for a client, optionally filters to those matching provided field values, excludes the current project, and renders a section of matching results.

// app/projects/[slug]/page.tsx
import { ProjectDetail, SimilarProjects } from "@chiselandco/nexus"

export default async function ProjectPage({
  params,
}: {
  params: Promise<{ slug: string }>
}) {
  const { slug } = await params
  const apiKey = process.env.YOUR_CLIENT_API_KEY!

  return (
    <>
      <ProjectDetail
        slug={slug}
        clientSlug="your-client-slug"
        apiBase="https://your-api.com"
        apiKey={apiKey}
      />
      <SimilarProjects
        excludeSlug={slug}
        clientSlug="your-client-slug"
        apiBase="https://your-api.com"
        apiKey={apiKey}
        basePath="/projects"
      />
    </>
  )
}

Filtering by field value

Pass filters to match projects that share a field value with the current project. The field values from the current project can be derived from the API response:

const apiKey = process.env.YOUR_CLIENT_API_KEY!
const res = await fetch(
  `${apiBase}/api/v1/clients/${clientSlug}/projects/${slug}?api_key=${apiKey}`,
  { next: { revalidate: 86400 } }
)
const project = res.ok ? (await res.json())?.data : null
// type may be a plain string or single-element array
const typeVal = project?.custom_field_values?.type
const projectType = Array.isArray(typeVal) ? typeVal[0] : typeVal ?? null

<SimilarProjects
  filters={projectType ? { type: projectType } : {}}
  excludeSlug={slug}
  clientSlug="your-client-slug"
  apiBase="https://your-api.com"
  apiKey={apiKey}
  basePath="/projects"
/>

Manually specifying projects

Pass projectSlugs to hand-pick exactly which projects appear. This overrides filters entirely and is the simplest approach when you want curated results. excludeSlug is still respected.

<SimilarProjects
  projectSlugs={[
    "jacob-javits-convention-center",
    "tillamook-bay-community-college",
    "lcisd-liberty-hill-high-school",
  ]}
  excludeSlug={slug}
  clientSlug="your-client-slug"
  apiBase="https://your-api.com"
  apiKey={process.env.YOUR_CLIENT_API_KEY!}
  basePath="/projects"
/>

Card variant

Use variant="card" to render baseball-card style instead of the default list style:

<SimilarProjects variant="card" ... />

| Prop | Type | Required | Default | Description | |---|---|---|---|---| | clientSlug | string | Yes | — | Identifies which client's projects to load | | apiBase | string | Yes | — | Base URL of the projects API | | apiKey | string | Yes | — | Client API key — always pass via environment variable, never hardcode | | filters | Record<string, string> | No | {} | Key/value pairs to filter by. All filters must match (AND logic) | | excludeSlug | string | No | — | Project slug to exclude from results | | basePath | string | No | "/projects" | Base path for project detail links | | projectSlugs | string[] | No | — | Explicit ordered list of slugs to show. Overrides filters when provided | | maxItems | number | No | 3 | Maximum number of projects to show | | title | string | No | "Similar Projects" | Section heading | | subtitle | string | No | "More Work" | Small uppercase label above the heading | | variant | "list" \| "card" | No | "list" | Display style | | font | string | No | System font stack | Font family string | | revalidate | number | No | 86400 | Cache revalidation period in seconds | | noCache | boolean | No | false | Sets cache: "no-store" — useful during development | | filterBy | { field: string; value: string } | No | — | Pre-filter projects by any custom field value. See Filtering by field. |


ProjectMenu

Server component. Megamenu that shows featured projects as compact cards on the left and "Browse By" filter links on the right. Drop it directly into a navigation dropdown.

// components/MegaMenu.tsx — must be a Server Component
import { ProjectMenu } from "@chiselandco/nexus"

export async function ProjectsMegaMenu() {
  return (
    <ProjectMenu
      clientSlug="your-client-slug"
      apiBase="https://your-api.com"
      apiKey={process.env.YOUR_CLIENT_API_KEY!}
      basePath="/projects"
      viewAllPath="/projects"
      subtitle="Our systems are installed in every geographic region of the U.S."
      maxProjects={6}
    />
  )
}

Pass menuId to show a specific curated set of projects instead of all projects:

<ProjectMenu
  clientSlug="your-client-slug"
  apiBase="https://your-api.com"
  apiKey={process.env.YOUR_CLIENT_API_KEY!}
  menuId="main-nav"
  basePath="/projects"
/>

| Prop | Type | Required | Default | Description | |---|---|---|---|---| | clientSlug | string | Yes | — | Identifies which client's projects to load | | apiBase | string | Yes | — | Base URL of the projects API | | apiKey | string | Yes | — | Client API key — always pass via environment variable, never hardcode | | menuId | string | No | — | Slug of a curated menu. When provided fetches from /menus/{slug}. Browse By filters always reflect the full schema. | | basePath | string | No | "/projects" | Base path for project detail links | | viewAllPath | string | No | Same as basePath | Path for the "View All Projects" link | | subtitle | string | No | — | Description shown above the project cards | | font | string | No | System font stack | Font family string | | maxProjects | number | No | 6 | Maximum number of projects to display | | revalidate | number | No | 86400 | Cache revalidation period in seconds | | noCache | boolean | No | false | Sets cache: "no-store" — useful during development | | filterBy | { field: string; value: string } | No | — | Pre-filter projects by any custom field value. See Filtering by field. |


ProjectMenuClient + createMenuHandler

Client component ("use client"). Use when your nav or header is a client component. Fetches and caches data on first mount — the API is never called twice on re-hover or remount.

Option 1 — dataUrl + createMenuHandler (recommended)

Create one API route. Data is server-cached for 24 hours.

// app/api/chisel-menu/route.ts
import { createMenuHandler } from "@chiselandco/nexus"

export const GET = createMenuHandler({
  clientSlug: "your-client-slug",
  apiBase: "https://your-api.com",
  apiKey: process.env.YOUR_CLIENT_API_KEY!,
})
// components/Nav.tsx
"use client"
import { ProjectMenuClient } from "@chiselandco/nexus"

export function Nav() {
  return (
    <ProjectMenuClient
      dataUrl="/api/chisel-menu"
      basePath="/projects"
      viewAllPath="/projects"
      subtitle="Explore our portfolio."
      maxProjects={6}
    />
  )
}

Option 2 — Direct fetch (quick setup)

No API route needed. The component fetches directly from the upstream API on first mount. Note: this exposes the API call to the client browser.

"use client"
import { ProjectMenuClient } from "@chiselandco/nexus"

export function Nav() {
  return (
    <ProjectMenuClient
      clientSlug="your-client-slug"
      apiBase="https://your-api.com"
      apiKey={process.env.YOUR_CLIENT_API_KEY!}
      basePath="/projects"
      viewAllPath="/projects"
    />
  )
}

| Prop | Type | Required | Default | Description | |---|---|---|---|---| | dataUrl | string | No* | — | URL of a local API route created with createMenuHandler() — recommended for production | | clientSlug | string | No* | — | Client slug for direct fetch mode | | apiBase | string | No* | — | API base URL for direct fetch mode | | apiKey | string | No* | ����� | Client API key for direct fetch mode | | menuId | string | No | — | Slug of a curated menu | | basePath | string | Yes | — | Base path for project detail links | | viewAllPath | string | Yes | — | Path for the "View All Projects" link | | subtitle | string | No | — | Description shown above the project cards | | font | string | No | System font stack | Font family string | | maxProjects | number | No | 6 | Maximum number of projects to display | | noCache | boolean | No | false | Bypasses the module-level data cache | | filterBy | { field: string; value: string } | No | — | Pre-filter projects by any custom field value. See Filtering by field. |

*One of dataUrl or clientSlug + apiBase + apiKey must be provided.


ProjectPortfolio

Server component. The primary projects grid. Fetches all projects, reads filter[key]= URL params server-side to narrow results, and renders a responsive card grid (1 col mobile / 2 col tablet / 3 col desktop). Pair with FilterSidebar when you want user-driven filtering — place it wherever suits your layout.

// app/projects/page.tsx
import { ProjectPortfolio } from "@chiselandco/nexus"

export default async function ProjectsPage({
  searchParams,
}: {
  searchParams: Promise<Record<string, string | string[] | undefined>>
}) {
  return (
    <ProjectPortfolio
      clientSlug="your-client-slug"
      apiBase="https://your-api.com"
      apiKey={process.env.YOUR_CLIENT_API_KEY!}
      basePath="/projects"
      searchParams={await searchParams}
    />
  )
}

| Prop | Type | Required | Default | Description | |---|---|---|---|---| | clientSlug | string | Yes | — | Identifies which client's projects to load | | apiBase | string | Yes | — | Base URL of the projects API | | apiKey | string | Yes | — | Client API key — always pass via environment variable, never hardcode | | basePath | string | No | "/projects" | Base path for project detail links | | searchParams | Record<string, string \| string[] \| undefined> | No | {} | Filter params — pass Next.js searchParams directly | | revalidate | number | No | 86400 | Cache revalidation period in seconds | | noCache | boolean | No | false | Sets cache: "no-store" — useful during development | | filterBy | { field: string; value: string } | No | — | Pre-filter projects by any custom field value. See Filtering by field. |


ProjectPortfolioClient

Client component ("use client"). Same grid as ProjectPortfolio but renders client-side. Fetches all projects once on mount (module-level cached). Use this inside a client component tree or when you want to build a custom filter UI that filters in memory.

"use client"
import { useState } from "react"
import { ProjectPortfolioClient } from "@chiselandco/nexus"

export default function ProjectsPage() {
  const [filters, setFilters] = useState<Record<string, string>>({})

  return (
    <>
      <select onChange={(e) => setFilters({ type: e.target.value })}>
        <option value="">All Types</option>
        <option value="commercial">Commercial</option>
      </select>
      <ProjectPortfolioClient
        clientSlug="your-client-slug"
        apiBase="https://your-api.com"
        apiKey={process.env.YOUR_CLIENT_API_KEY!}
        basePath="/projects"
        filters={filters}
      />
    </>
  )
}

| Prop | Type | Required | Default | Description | |---|---|---|---|---| | clientSlug | string | Yes | — | Identifies which client's projects to load | | apiBase | string | Yes | — | Base URL of the projects API | | apiKey | string | Yes | — | Client API key — always pass via environment variable, never hardcode | | basePath | string | No | "/projects" | Base path for project detail links | | filters | Record<string, string> | No | {} | Active filters — filtering is instant, no API call on change | | columns | 2 \| 3 | No | 3 | Number of grid columns | | font | string | No | System font stack | Font family string | | filterBy | { field: string; value: string } | No | — | Pre-filter projects by any custom field value. See Filtering by field. |


Filtering by field

ProjectPortfolio, ProjectPortfolioClient, SimilarProjects, ProjectMenu, and ProjectMenuClient all accept an optional filterBy prop. It pre-filters the project list by any custom field value before any user-driven filters are applied.

// Only show projects where custom field "side" equals "architectural" or "both"
<ProjectPortfolio
  clientSlug="hollaender"
  apiBase="https://your-api.com"
  apiKey={process.env.HOLLAENDER_API_KEY!}
  basePath="/architectural/projects"
  searchParams={searchParams}
  filterBy={{ field: "side", value: "architectural" }}
/>

// Only show projects where custom field "side" equals "speedrail" or "both"
<ProjectPortfolio
  clientSlug="hollaender"
  apiBase="https://your-api.com"
  apiKey={process.env.HOLLAENDER_API_KEY!}
  basePath="/speedrail/projects"
  searchParams={searchParams}
  filterBy={{ field: "side", value: "speedrail" }}
/>

// Works with any field — not just "side"
<ProjectPortfolio
  clientSlug="acme"
  apiBase="https://your-api.com"
  apiKey={process.env.ACME_API_KEY!}
  filterBy={{ field: "region", value: "northeast" }}
/>

// Nav menu filtered to the same side — keeps menu and portfolio in sync
<ProjectMenu
  clientSlug="hollaender"
  apiBase="https://your-api.com"
  apiKey={process.env.HOLLAENDER_API_KEY!}
  basePath="/architectural/projects"
  filterBy={{ field: "side", value: "architectural" }}
/>

The "both" fallback is built in — if a project's field value is "both" it matches any filterBy.value. When filterBy is omitted all projects are shown. No extra API calls are made — filtering happens in memory after the standard fetch.

Migration from v2 side prop

// v2
<ProjectPortfolio side="architectural" />

// v3
<ProjectPortfolio filterBy={{ field: "side", value: "architectural" }} />

Migration from v3.0 FilteredPortfolio

FilteredPortfolio was removed in v3.1. Use ProjectPortfolio directly — it has the same props. Pair with FilterSidebar if you want a filter drawer.

// v3.0
import { FilteredPortfolio } from "@chiselandco/nexus"
<FilteredPortfolio clientSlug="..." apiBase="..." apiKey={...} searchParams={searchParams} />

// v3.1
import { ProjectPortfolio } from "@chiselandco/nexus"
<ProjectPortfolio clientSlug="..." apiBase="..." apiKey={...} searchParams={searchParams} />

Server vs Client components

| Component | Type | Notes | |---|---|---| | ProjectPortfolio | Server | Primary projects grid | | FilterSidebar | Client | Optional filter drawer — pair with ProjectPortfolio | | ProjectPortfolioClient | Client | For use inside client component trees | | ProjectDetail | Server | Full project detail page | | GalleryCarousel | Client | Used internally by ProjectDetail | | SimilarProjects | Server | After ProjectDetail on detail pages | | ProjectMenu | Server | Server-rendered nav megamenu | | ProjectMenuClient | Client | Client-rendered nav megamenu |

All server components must be rendered in a server context. If your parent component uses "use client", use the client variants or pass server components as children from a server parent.


Caching

| Component | Server cache | Client cache | |---|---|---| | ProjectPortfolio | 24h via next.revalidate | — | | ProjectDetail | 24h via next.revalidate | — | | SimilarProjects | 24h via next.revalidate | — | | ProjectMenu | 24h via next.revalidate | — | | ProjectMenuClient + createMenuHandler | 24h (route handler) | Per-session module cache | | ProjectMenuClient (direct fetch) | None | Per-session module cache | | ProjectPortfolioClient | None | Per-session module cache |

Pass noCache={true} on any server component to bypass the cache during development. To invalidate the server cache from a CMS webhook:

import { revalidateTag } from "next/cache"
revalidateTag("chisel-menu-your-client-slug")
// For a curated menu:
revalidateTag("chisel-menu-your-client-slug-main-nav")

Image optimisation

All image-rendering components use Next.js <Image> from next/image instead of plain <img> tags. This gives you automatic resizing, compression, lazy loading, and WebP/AVIF conversion at the Next.js layer — no full-resolution originals hit the browser.

Required next.config.js setup

Because project images are hosted on an external domain, every consuming app must whitelist that domain in remotePatterns. Without this, Next.js will refuse to optimise the images and return a 400 error.

// next.config.js (or next.config.ts)
const nextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: "https",
        hostname: "**.public.blob.vercel-storage.com",
      },
    ],
  },
}

module.exports = nextConfig

This covers all Vercel Blob-hosted images. If your client's images are hosted elsewhere (e.g. a custom CDN or S3 bucket), add that hostname to remotePatterns as well.

Image sizes used

| Component | Context | sizes hint | |---|---|---| | ProjectCard hero | Portfolio grid | (max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw | | ProjectCard compact | List view | 160px | | ProjectMenuClient thumbnail | Nav menu | 144px | | GalleryCarousel main image | Project detail | 100vw | | GalleryCarousel thumbnail strip | Project detail | 160px | | SimilarProjects card | Related projects | (max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw |


Publishing

npm login
cd package
npm run build
npm publish --access public

To release an update, bump the version field in package/package.json then run npm publish again.