@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
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/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
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:
- 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.
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 = 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.
