@chiselandco/nexus
v3.14.1
Published
Self-contained project portfolio components for Next.js App Router. Includes ProjectPortfolio, ProjectPortfolioClient, ProjectDetail, SimilarProjects, ProjectMenu, ProjectMenuClient, GalleryCarousel, ProjectFilters, ProjectSearch (now with live search pre
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.9.0
Requirements
- Next.js 13+ (App Router)
- React 18+
No other dependencies required.
Installation
npm install @chiselandco/nexusQuick 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
ProjectFilters
Server component. The page masthead for a project index: kicker, title, result count and a URL-driven filter list, set as one ruled unit. Renders one row per filterable field, deriving every option from the client's custom field schema — so the interface adapts per client with no configuration.
Each option is a real <a href> pointing at a canonical ?filter[key]=value URL. That makes filtered views crawlable by search engines, shareable, back-button friendly, and functional with JavaScript disabled — the main reason to prefer this over FilterSidebar.
The filter list is a plain vertical accordion, not a dropdown. Each field is a native <details>/<summary> row that reveals its options directly beneath itself in normal document flow — nothing floats over the grid or over a sibling row, so there is nothing to overlap regardless of how many fields the schema has or how many are open at once. The whole list also nests inside one outer <details> — the master toggle — so a single "Filters" control collapses or expands every field row at once, while each row underneath it can still be opened or closed individually whenever the master is expanded. Nesting <details> inside <details> is valid HTML, so this costs nothing beyond the element itself: no JS, no state to keep in sync, and because closed <details> content stays in the DOM, every option link is still present in the server HTML for crawlers.
The master defaults to closed unless a filter from the current URL is already active, in which case it opens automatically — so a first-time visitor sees one compact "Filters" line above the grid rather than five open rows, but a visitor returning to a filtered link sees their selection immediately.
Each option row also shows a live match count — how many of the currently-loaded projects carry that option, computed from the same data already fetched for the grid. An option with zero matches under the current filter/search state stays clickable (combined with a different filter it may not be empty) but visibly recedes, set in a monospace figure style shared with every other number on the page.
Why the header lives here and not on the page. A filter list floating alone above a card grid reads as a widget dropped onto the page rather than part of it. The title, the result count and the controls are one thought — "what am I looking at, how much of it is there, how do I narrow it" — so they share one container and one baseline rule. Folding them together also closed a real gap: the index pages had no <h1> at all. Set heading to opt in; omit it and only the filter list renders, for pages that bring their own header.
The kicker is a live readout of the active criteria, not a label. Unfiltered it reads Complete Index; with filters applied it becomes Interna-Rail with Picket Infill · Clear Anodized. That is the one thing nothing else in the masthead says — the count reports how many results there are and the title names the page, but neither names what you are looking at. It previously held the client name, which restated the host site's own branding and did no work.
It is capped at two labels with a +N overflow. Option labels in this domain run long, and a third wrapped the kicker onto a second line, which shifted the title down as filters were applied; with the cap the title holds a fixed position across every filter state. Pass eyebrow to pin it to fixed copy instead, or emptyEyebrow to change only the unfiltered text.
The count works on the same principle — 16 Projects normally, 3 Matches under an active filter — so the figure is never mistaken for the size of the whole catalog. It renders as a large monospace figure next to the title, deliberately a different typographic voice than the sans-serif heading beside it.
Visually it borrows the card system rather than inventing one: the same #d4d4d8 hairline borders and square corners, the same #f18a00 accent — repeated as a short tick mark ahead of both the kicker and the master toggle label, so the two read as one system. The single 2px rule under the title is the one heavy mark on the page — everything else stays hairline.
Place it directly above ProjectPortfolio and pass both the same searchParams, revalidate, and noCache. React.cache() then dedupes the two components into a single API call per render.
Splitting the masthead from the filter list. ProjectFilters can be called twice on one page — once with heading and hideFilterBar to render just the title/kicker/rule, and again with layout="toolbar" to render just the accordion — so something else (typically ProjectSearch) can sit visually between the two. Both calls share the same searchParams/noCache, so React.cache() still collapses all the data fetching into one request regardless of how many times the component is called on the page.
import { ProjectFilters, ProjectSearch, ProjectPortfolio } from "@chiselandco/nexus"
export default async function Page({ params, searchParams }) {
const { clientSlug } = await params
const resolvedSearch = await searchParams
const pathname = `/${clientSlug}`
return (
<>
{/* Masthead only — heading turns it on, hideFilterBar keeps the accordion out */}
<ProjectFilters
clientSlug={clientSlug}
apiBase={API_BASE}
apiKey={apiKey}
pathname={pathname}
searchParams={resolvedSearch}
heading="Project Index"
description="Installations selected from the field."
hideFilterBar
/>
{/* Full-width search, given top billing directly under the masthead */}
<ProjectSearch
clientSlug={clientSlug}
apiBase={API_BASE}
apiKey={apiKey}
pathname={pathname}
searchParams={resolvedSearch}
label={null}
/>
{/* Filter accordion only — no heading/rule of its own */}
<ProjectFilters
clientSlug={clientSlug}
apiBase={API_BASE}
apiKey={apiKey}
pathname={pathname}
searchParams={resolvedSearch}
layout="toolbar"
showCount={false}
/>
<ProjectPortfolio
clientSlug={clientSlug}
apiBase={API_BASE}
apiKey={apiKey}
searchParams={resolvedSearch}
basePath={`/${clientSlug}/projects`}
clearFiltersHref={`/${clientSlug}`}
showFilterBanner={false}
/>
</>
)
}| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
| clientSlug | string | Yes | — | Which client's schema to load |
| apiBase | string | Yes | — | Base URL of the projects API |
| apiKey | string | Yes | — | Client API key — pass via env var |
| pathname | string | Yes | — | Path of the page the filters live on. Every link points back here. Not the detail basePath |
| searchParams | Record<string, string \| string[] \| undefined> | No | {} | Current request's searchParams — drives which options are active |
| fields | string[] | No | All filterable fields with options | Restrict to these field keys, in this order |
| includeHidden | boolean | No | false | Include fields the schema marks display_position: "hidden" |
| heading | string | No | — | Page title. Set it to render the full masthead; omit for the filter list alone |
| eyebrow | string \| null | No | Active filter criteria | Kicker above the title. Defaults to a live readout of the active criteria, falling back to emptyEyebrow. Pass a string to pin it; null omits it |
| emptyEyebrow | string | No | "Complete Index" | Kicker text when no filters are active. Ignored if eyebrow is set |
| headingLevel | 1 \| 2 | No | 1 | Use 2 if the host page already renders its own h1 |
| description | string | No | — | Short line of copy under the title |
| label | string \| null | No | "Filters" | Label on the master toggle that collapses/expands the whole filter list at once. null always renders the list expanded, with no master toggle at all |
| defaultOpen | boolean | No | false | Whether the filter list starts expanded under the master toggle when no filter is currently active. Whenever a filter is already active, the list opens regardless of this prop |
| showCount | boolean | No | true | Show the result count — in the masthead when heading is set, inline otherwise |
| revalidate | number | No | 60 | Data Cache seconds. Must match ProjectPortfolio to share one fetch |
| noCache | boolean | No | false | Disable caching. Must match ProjectPortfolio to share one fetch |
| searchParamName | string | No | "q" | Query param name an adjacent ProjectSearch reads/writes. Every filter link carries this param's current value through, so toggling a facet never clears an active search |
| layout | "stacked" \| "toolbar" | No | "stacked" | "stacked" renders the full masthead plus the filter list below it. "toolbar" renders just the bare filter list (no heading, no rule) — pair with a second hideFilterBar call for the masthead, and with ProjectSearch in between |
| hideFilterBar | boolean | No | false | Render the masthead only, without the filter list itself. Pairs with a second layout="toolbar" call — React.cache() dedupes both calls' data fetch into one |
Behaviour
- Multiple values within one field are OR'd, serialised as CSV:
?filter[system]=structural-glass,interna-light - Separate fields are AND'd:
?filter[system]=structural-glass&filter[finish]=clear-anodized - Every option link is both the add and the remove affordance — active options link to the URL with themselves removed
- Active selections are shown on the field row itself: one selection renders inline as its label next to the field name, several collapse to a count badge. A field row with an active selection also defaults open, so a visitor returning to a filtered URL sees their choice already expanded rather than having to reopen the row to find it
- A field row with 1+ active values gets a "Clear <field>" link in its panel; the master toggle area gets "Clear all" whenever anything is active
- Fields marked
display_position: "hidden"are skipped unlessincludeHiddenis set
ProjectFilters vs FilterSidebar
| | ProjectFilters | FilterSidebar |
|---|---|---|
| Rendering | Server component | Client component |
| Form factor | Inline accordion, collapsible as a whole | Drawer behind a trigger |
| Options | Auto-fetched from schema | You pass schema in |
| Crawlable links | Yes — real anchors | No — JS-driven |
| Needs JS | No | Yes |
Prefer ProjectFilters for SEO-relevant public pages. Use FilterSidebar when space is tight and you want filters tucked away.
ProjectSearch
Server component. A free-text search box paired with ProjectPortfolio, designed to sit directly above ProjectFilters' filter list — pass label={null} and it renders as one full-width bar with no micro-label of its own, so it reads as the primary way into the grid rather than one more labelled form field.
The baseline is a real <form method="GET">: submitting navigates to a canonical ?q=... URL, so search results are shareable and work with JavaScript disabled. Active filter[key] params are carried through as hidden inputs, so searching never clears an active filter. The search itself runs server-side against Nexus's search= param — a case-insensitive substring match across title, blurb, and description — so a plain-GET result is already narrowed from the full dataset.
With JavaScript, SearchPreviewField (a small client island rendered inside this same form) adds a live, debounced typeahead dropdown with thumbnails — every keystroke hits a lightweight preview endpoint instead of triggering a full-page navigation. Selecting a row goes straight to that project; pressing Enter with nothing highlighted, or clicking "view all", falls through to the form's normal submit and lands on the full filtered grid.
Place it beside or above ProjectFilters and pass the same searchParams, revalidate, and noCache — React.cache() dedupes all the data fetching into one API call.
import { ProjectSearch } from "@chiselandco/nexus"
<ProjectSearch
clientSlug={clientSlug}
apiBase={API_BASE}
apiKey={apiKey}
pathname={`/${clientSlug}`}
searchParams={resolvedSearch}
placeholder="Search by name, material, application…"
projectBasePath={`/${clientSlug}/projects`}
label={null}
/>| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
| clientSlug | string | Yes | — | Client slug identifying which client's projects to search |
| apiBase | string | Yes | — | Base URL of the projects API |
| apiKey | string | Yes | — | Client API key — pass via env var |
| pathname | string | Yes | — | Path of the page the search box lives on. Submitting points back here with an updated ?q= param. Not the project detail basePath |
| searchParams | Record<string, string \| string[] \| undefined> | No | {} | Current request's searchParams — drives the box's current value |
| paramName | string | No | "q" | Query string param name |
| placeholder | string | No | "Search projects…" | Input placeholder text |
| label | string \| null | No | "Search" | Micro-label above the input — visible on desktop, sr-only under 640px. Pass null to omit it, e.g. when the box sits directly under a masthead that already says what the page is. Ignored in layout="toolbar" |
| showCount | boolean | No | true | Show a "N results for '…'" line under the box while a query is active |
| revalidate | number | No | 60 | Data Cache seconds. Match ProjectFilters/ProjectPortfolio to share one fetch |
| noCache | boolean | No | false | Disable caching. Match ProjectFilters/ProjectPortfolio to share one fetch |
| projectBasePath | string | No | "/projects" | Base path for project detail links, used by the live preview dropdown. Should match whatever basePath is passed to ProjectPortfolio/ProjectCard |
| previewEndpoint | string | No | `/api/${clientSlug}/search-preview` | Path of the preview API route. Only needs overriding if that route was mounted somewhere else |
| previewLimit | number | No | 6 | Max rows shown in the live preview dropdown |
| layout | "stacked" \| "toolbar" | No | "stacked" | "stacked" renders the full-width control at 100% of its container. "toolbar" constrains it to a flex-basis instead, sized to sit flush beside a layout="toolbar" ProjectFilters in a shared row |
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 (case studies included), a GalleryCarousel with filterable media tag pills, and a categorized Drawings / Technical Specifications downloads section.
// 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:
- An explicit entry in the
fieldPlacementprop (keyed by the field's schemakey), if provided. - Otherwise a sensible default derived from
display_position:metadata→"stats",tags→"sidebar", andbadge_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 combinedcity, statestring; 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_overlayfield renders as the hero badge. For amulti-selectbadge the first option is shown; for a single-value badge (e.g. atextlocation like"Mason, OH") the full value is shown verbatim — it is not comma-split, so multi-part values keep every part.
Documents & Resources
If a project has an attachments array (PDFs, .docx, .rvt, etc.), ProjectDetail splits them by inferred category and renders each group where it's most useful to a visitor, rather than one undifferentiated pile:
| Category | Where it renders | How it's identified |
|---|---|---|
| Case study | Specs sidebar, above the field chips | Filename contains "case study" (any spacing/hyphenation, case-insensitive) |
| Drawing | Body, under a Drawings heading | .rvt extension, or any other PDF that isn't a case study |
| Technical specification | Body, under a Technical Specifications heading | .docx extension |
Case studies read as background material about the project itself — closer in kind to the metadata beside them than to the reference-file downloads further down the page — so they join the spec chips in the sidebar instead of the body grid. Drawings and specs stay in a Documents & Resources section below the gallery (with a "Downloads" eyebrow matching the page's section rhythm), each in its own responsive two-column grid under its own subheading. Every card shows a file-type badge (the extension, e.g. PDF, with a document-glyph fallback), the file name, an EXTENSION · size meta line, and — in the body grids — a circular download button that fills on hover. Cards are ordered by sort_order within their group.
There is no admin-side category field — the admin portal doesn't support one yet — so categorization is inferred client-side from the extension and, for PDFs, the filename. This means it only works as well as case-study files are actually named consistently; a PDF drawing accidentally named ... case study ... will be mis-sorted into the sidebar. Any group with zero attachments is omitted entirely, so the feature is safe across clients that don't use it, and a project with only drawings (no case study, no specs) still gets a "Drawings"-only Documents & Resources section.
Each attachment has the shape:
interface Attachment {
id: string
name: string // display name
url: string // public file URL
file_type: string // MIME type, e.g. "application/pdf"
file_extension: string // e.g. "pdf"
size_bytes: number
sort_order: number
}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. |
| showFilterBanner | boolean | No | true | Show the built-in "Filtered by: …" banner. Set false when pairing with ProjectFilters, which already surfaces active filters |
| clearFiltersHref | string | No | basePath | Href for the "Clear filters" links. Set to the page's own path when the grid does not live at basePath |
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 = nextConfigThis 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 publicTo release an update, bump the version field in package/package.json then run npm publish again.
