@chiselandco/nexus
v2.2.7
Published
Self-contained project portfolio components for Next.js App Router. Includes ProjectPortfolio, ProjectPortfolioClient (with built-in filtering), ProjectDetail, SimilarProjects, ProjectMenu, ProjectMenuClient, and GalleryCarousel. Pass a clientSlug and api
Readme
@chiselandco/nexus
A suite of self-contained project portfolio components for Next.js App Router. Drop in a clientSlug and apiBase — each component fetches, caches, and renders everything it needs with zero client-side waterfall requests.
Requirements
- Next.js 13+ (App Router)
- React 18+
No other dependencies required.
Installation
npm install @chiselandco/nexusQuick Start
Here is the most common full setup — a projects grid page, 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: { [key: string]: string | string[] | undefined }
}) {
return (
<ProjectPortfolio
clientSlug="your-client-slug"
apiBase="https://your-api.com"
basePath="/projects"
searchParams={searchParams}
/>
)
}// app/projects/[slug]/page.tsx
import { ProjectDetail, SimilarProjects } from "@chiselandco/nexus"
export default async function ProjectPage({ params }: { params: { slug: string } }) {
return (
<>
<ProjectDetail
slug={params.slug}
clientSlug="your-client-slug"
apiBase="https://your-api.com"
backPath="/projects"
/>
{/* Hardcode the slugs you want shown — update per client request */}
<SimilarProjects
projectSlugs={[
"jacob-javits-convention-center",
"tillamook-bay-community-college",
"lcisd-liberty-hill-high-school",
]}
excludeSlug={params.slug}
clientSlug="your-client-slug"
apiBase="https://your-api.com"
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",
})// components/Nav.tsx — "use client" component in your header
"use client"
import { ProjectMenuClient } from "@chiselandco/nexus"
export function Nav() {
return (
<nav>
{/* ... other nav items ... */}
<ProjectMenuClient
dataUrl="/api/chisel-menu"
basePath="/projects"
viewAllPath="/projects"
/>
</nav>
)
}Components
ProjectPortfolio
A full server-rendered project grid page. Fetches all projects for a client and renders them as responsive baseball cards (1 column on mobile, 2 on tablet, 3 on desktop). Supports URL-driven filtering via searchParams.
// app/projects/page.tsx
import { ProjectPortfolio } from "@chiselandco/nexus"
export default async function ProjectsPage({
searchParams,
}: {
searchParams: { [key: string]: string | string[] | undefined }
}) {
return (
<ProjectPortfolio
clientSlug="your-client-slug"
apiBase="https://your-api.com"
basePath="/projects"
searchParams={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 |
| basePath | string | No | "/projects" | Base path for project detail links |
| searchParams | Record<string, string \| string[] \| undefined> | No | {} | Filter params forwarded to the API — pass Next.js searchParams directly |
| revalidate | number | No | 86400 | Cache revalidation period in seconds (24 hours) |
URL-driven filtering
When a user clicks a "Browse By" filter link in the menu, they land on the grid with query params in the URL. Pass searchParams from the page and ProjectPortfolio forwards them to the API automatically.
Filter URLs follow this pattern — the key matches the custom field key in the schema:
/projects?filter[type]=commercial
/projects?filter[type]=educational-facilitiesWhen active filters are applied, a filter banner is shown above the grid with a "Clear filters" link back to basePath.
ProjectPortfolioClient
A "use client" version of the project grid. Fetches all projects once on mount (module-level cached — no re-fetch on remount) and filters them locally in memory. Use this when you need the grid inside a client component tree, or when you want to build your own custom filter UI.
// Works in any component — no RSC required
import { ProjectPortfolioClient } from "@chiselandco/nexus"
export default function ProjectsPage() {
return (
<ProjectPortfolioClient
clientSlug="your-client-slug"
apiBase="https://your-api.com"
basePath="/projects"
/>
)
}With a custom filter UI
Wire up your own dropdowns, buttons, or search inputs using the filters prop. Filter changes are instant — no API call on each change, all filtering happens 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 (
<>
{/* Your own filter UI */}
<select onChange={(e) => setFilters({ type: e.target.value })}>
<option value="">All Types</option>
<option value="commercial">Commercial</option>
<option value="educational-facilities">Educational</option>
</select>
<ProjectPortfolioClient
clientSlug="your-client-slug"
apiBase="https://your-api.com"
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 |
| basePath | string | No | "/projects" | Base path for project detail links |
| filters | Record<string, string> | No | {} | Active filters keyed by custom field key — filtering is instant, no API call on change |
| columns | 2 \| 3 | No | 3 | Number of columns in the project grid |
| font | string | No | System font stack | Font family string applied to all text |
ProjectDetail
A full server-rendered project detail page. Fetches a single project by slug and renders a hero image, a dynamic stats bar, a "Project Overview" section with description and specs sidebar, a photo gallery, and a back link.
// app/projects/[slug]/page.tsx
import { ProjectDetail } from "@chiselandco/nexus"
export default async function ProjectPage({ params }: { params: { slug: string } }) {
return (
<ProjectDetail
slug={params.slug}
clientSlug="your-client-slug"
apiBase="https://your-api.com"
backPath="/projects"
backLabel="All Projects"
/>
)
}Stats bar
The stats bar below the hero is driven by an explicit ordered key list. Fields are shown in this order when present in the schema and populated on the project:
| Schema key | Label shown |
|---|---|
| location | Location |
| type (badge field) | field name from schema |
| coverage | Coverage |
| year-completed | Completed |
| architect | Architect |
| general-contractor | General Contractor |
If a field doesn't exist in the schema for a given client, or the project has no value for it, that stat is silently omitted. The column count adjusts automatically — 2 columns on mobile, 3 on tablet, up to 6 on desktop.
Project Overview specs sidebar
The "Project Overview" section renders the project description on the left and a specs sidebar on the right (amber accent border). The sidebar shows the following fields when populated, in this order:
| Schema key | Label shown |
|---|---|
| systems-used | Systems Used |
| systems | Track Systems |
| series-used | Series Used |
| operation-type | Operation Type |
| finishes | Finishes |
| specifications | Specifications |
Each group renders values as outlined pills. Fields with no value for the current project are omitted. Both the stats bar and the specs sidebar are fully schema-driven — if a key doesn't exist in a client's schema it is simply not shown, making ProjectDetail safe to reuse across clients with wildly different field configurations.
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
| slug | string | Yes | — | The project slug to load |
| clientSlug | string | Yes | — | The client slug that owns this project |
| apiBase | string | Yes | — | Base URL of the projects API |
| 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 (24 hours) |
| noCache | boolean | No | false | Bypasses the Next.js Data Cache and sets cache: "no-store". Useful during development or for frequently updated projects. |
GalleryCarousel
A "use client" image carousel with previous/next arrows, a counter badge, and a scrollable thumbnail strip. Used internally by ProjectDetail but can also be used standalone if you fetch your own project data.
"use client"
import { GalleryCarousel } from "@chiselandco/nexus"
// `media` is the array of image objects returned by the projects API
export function ProjectGallery({ media, title }: { media: Media[]; title: string }) {
return (
<GalleryCarousel
images={media}
projectTitle={title}
/>
)
}The main image and thumbnails are standardised to a 16/9 aspect ratio — no external CSS required.
| 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 |
SimilarProjects
A server-rendered similar projects section. Fetches all projects for a client, filters to those matching the provided field values, excludes the current project, and renders up to 3 matching results. Designed to be placed after ProjectDetail on a project detail page.
// app/projects/[slug]/page.tsx
import { ProjectDetail, SimilarProjects } from "@chiselandco/nexus"
export default async function ProjectPage({ params }: { params: { slug: string } }) {
return (
<>
<ProjectDetail
slug={params.slug}
clientSlug="your-client-slug"
apiBase="https://your-api.com"
/>
<SimilarProjects
filters={{ type: "commercial" }}
excludeSlug={params.slug}
clientSlug="your-client-slug"
apiBase="https://your-api.com"
basePath="/projects"
/>
</>
)
}Deriving filters from the current project
In most real-world cases you want to match similar projects based on the current project's own field values. Fetch the project first, then pass its field values as filters:
// app/projects/[slug]/page.tsx
import { ProjectDetail, SimilarProjects } from "@chiselandco/nexus"
async function getProject(slug: string, clientSlug: string, apiBase: string) {
const res = await fetch(
`${apiBase}/api/v1/clients/${clientSlug}/projects/${slug}?api_key=YOUR_API_KEY`,
{ next: { revalidate: 86400 } }
)
return res.ok ? (await res.json())?.data : null
}
export default async function ProjectPage({ params }: { params: { slug: string } }) {
const project = await getProject(params.slug, "your-client-slug", "https://your-api.com")
const projectType = project?.custom_field_values?.type ?? null
return (
<>
<ProjectDetail slug={params.slug} clientSlug="your-client-slug" apiBase="https://your-api.com" />
{projectType && (
<SimilarProjects
filters={{ type: projectType }}
excludeSlug={params.slug}
clientSlug="your-client-slug"
apiBase="https://your-api.com"
basePath="/projects"
/>
)}
</>
)
}Manually specifying projects (recommended for quick setup)
Pass projectSlugs to hand-pick exactly which projects appear, in the order you specify. This overrides filters entirely — useful when you want to curate the section rather than rely on field matching, or when you need a reliable result quickly without worrying about field values matching.
This is the recommended approach for most client sites. The slugs are hardcoded in the page file. If a client wants different projects shown, update the slugs and redeploy.
// app/projects/[slug]/page.tsx
import { ProjectDetail, SimilarProjects } from "@chiselandco/nexus"
export default async function ProjectPage({ params }: { params: { slug: string } }) {
return (
<>
{/* All your other page content above */}
<ProjectDetail
slug={params.slug}
clientSlug="your-client-slug"
apiBase="https://your-api.com"
backPath="/projects"
/>
{/* Similar projects hardcoded — update slugs per client request */}
<SimilarProjects
projectSlugs={[
"jacob-javits-convention-center",
"tillamook-bay-community-college",
"lcisd-liberty-hill-high-school",
]}
excludeSlug={params.slug}
clientSlug="your-client-slug"
apiBase="https://your-api.com"
basePath="/projects"
/>
</>
)
}
excludeSlugis still respected even inprojectSlugsmode — if the current page's slug appears in the list it is automatically removed so a project never links to itself.
To update which projects appear, find the projectSlugs array in the page file and swap in the new slugs. Project slugs are visible in the URL when browsing the portfolio: /projects/jacob-javits-convention-center → slug is jacob-javits-convention-center.
Card variant
Use variant="card" to render the same baseball-card style used in ProjectPortfolio instead of the default list style:
<SimilarProjects
filters={{ type: "commercial" }}
excludeSlug={params.slug}
clientSlug="your-client-slug"
apiBase="https://your-api.com"
basePath="/projects"
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 |
| filters | Record<string, string> | No | {} | Key/value pairs to filter projects by custom field values. All filters must match (AND logic) |
| excludeSlug | string | No | — | Slug of a project to exclude from results (e.g. the current project) |
| basePath | string | No | "/projects" | Base path for project detail links |
| projectSlugs | string[] | No | — | Explicit list of project slugs to show, in order. When provided, overrides filters entirely. e.g. ["jacob-javits", "tillamook-bay"] |
| maxItems | number | No | 3 | Maximum number of projects to show |
| title | string | No | "Similar Projects" | Heading text for the section. e.g. "Featured Projects" |
| subtitle | string | No | "More Work" | Small uppercase label above the heading. e.g. "Our Work" |
| variant | "list" \| "card" | No | "list" | "list" uses the border-bottom separator style; "card" renders full baseball-card style matching ProjectPortfolio |
| font | string | No | System font stack | Font family string applied to all inline styles |
| revalidate | number | No | 86400 | Cache revalidation period in seconds (24 hours) |
| noCache | boolean | No | false | Bypasses the Next.js Data Cache and sets cache: "no-store". Useful during development or for frequently updated projects. |
ProjectMenu
A server-rendered megamenu component. Shows featured projects as compact cards on the left and "Browse By" filter links on the right. Designed to be dropped directly into a navigation dropdown.
// components/MegaMenu.tsx
import { ProjectMenu } from "@chiselandco/nexus"
// Must be a Server Component — do NOT add "use client"
export async function ProjectsMegaMenu() {
return (
<ProjectMenu
clientSlug="your-client-slug"
apiBase="https://your-api.com"
basePath="/projects"
subtitle="Our systems are installed in every geographic region of the U.S."
maxProjects={6}
/>
)
}With a curated menu
Pass menuId to show a specific curated set of projects instead of all projects. The value should be the menu slug (not the UUID) — available slugs can be retrieved from GET /api/v1/clients/{clientSlug}/menus. The Browse By filters on the right always reflect the full schema regardless of which menu is active.
<ProjectMenu
clientSlug="your-client-slug"
apiBase="https://your-api.com"
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 |
| menuId | string | No | — | Slug of a curated menu. When provided, fetches from /menus/{slug} instead of all projects. Filters always shown regardless. |
| 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 paragraph shown above the project cards |
| font | string | No | System font stack | Font family string applied to all inline styles |
| maxProjects | number | No | 6 | Maximum number of projects to display |
| revalidate | number | No | 86400 | Cache revalidation period in seconds (24 hours) |
| noCache | boolean | No | false | Bypasses the Next.js Data Cache and sets cache: "no-store". Useful during development or for frequently updated projects. |
ProjectMenuClient + createMenuHandler
ProjectMenuClient is a "use client" megamenu component. Use it when your nav or header is a client component. It fetches and caches data on first mount so the API is never called twice on re-hover or remount.
There are two ways to set it up:
Option 1 — dataUrl + createMenuHandler (recommended for production)
Create one API route once. The data is server-cached for 24 hours — most users never trigger a call to the upstream API at all.
// app/api/chisel-menu/route.ts
import { createMenuHandler } from "@chiselandco/nexus"
export const GET = createMenuHandler({
clientSlug: "your-client-slug",
apiBase: "https://your-api.com",
})For a curated menu, pass menuId to the handler:
// app/api/chisel-menu/route.ts
export const GET = createMenuHandler({
clientSlug: "your-client-slug",
apiBase: "https://your-api.com",
menuId: "main-nav",
})Then pass dataUrl to the component:
// 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 of projects."
maxProjects={6}
/>
)
}Option 2 — Direct fetch (quick setup / non-Next.js environments)
No API route needed. The component fetches directly from the upstream API on first mount and caches the result in memory for the session. 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"
basePath="/projects"
viewAllPath="/projects"
/>
)
}With a curated menu:
<ProjectMenuClient
clientSlug="your-client-slug"
apiBase="https://your-api.com"
menuId="main-nav"
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 |
| menuId | string | No | — | Slug of a curated menu. Fetches from /menus/{slug} for projects. Filters are always shown regardless. |
| noCache | boolean | No | false | Bypasses the module-level data cache and sets cache: "no-store" on all fetch calls. Useful during development or when projects update frequently. |
| 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 (hidden on mobile) |
| font | string | No | System font stack | Font family string |
| maxProjects | number | No | 6 | Maximum number of projects to display (capped at 3 on mobile) |
*One of dataUrl or clientSlug + apiBase must be provided.
Server vs Client Components
All top-level components except ProjectMenuClient, ProjectPortfolioClient, and GalleryCarousel are async Server Components. They must be rendered in a server context:
// CORRECT
export default async function Page() {
return <ProjectPortfolio clientSlug="..." apiBase="..." />
}
// WRONG — causes a runtime error
"use client"
export default function Page() {
return <ProjectPortfolio clientSlug="..." apiBase="..." />
}If your parent component uses "use client", use the client variants instead (ProjectMenuClient, ProjectPortfolioClient) or pass the server components as children from a server parent.
| Component | Type | Use when |
|---|---|---|
| ProjectPortfolio | Server | Page-level grid, URL-driven filters |
| ProjectPortfolioClient | Client | Inside a client tree, custom filter UI |
| ProjectDetail | Server | Project detail page |
| GalleryCarousel | Client | Standalone image gallery |
| ProjectMenu | Server | Server-rendered nav dropdown |
| ProjectMenuClient | Client | Client-rendered nav/header |
| SimilarProjects | Server | After ProjectDetail on detail pages |
Caching
| Component | Server Cache | Client Cache |
|---|---|---|
| ProjectMenu (RSC) | 24h via next.revalidate | — |
| ProjectMenuClient + createMenuHandler | 24h (route handler) | Per-session module cache |
| ProjectMenuClient (direct fetch) | None | Per-session module cache |
| ProjectPortfolio (RSC) | 24h via next.revalidate | — |
| ProjectPortfolioClient | None | Per-session module cache |
| ProjectDetail (RSC) | 24h via next.revalidate | — |
| SimilarProjects (RSC) | 24h via next.revalidate | — |
Bypassing the cache
| Method | Scope | Use case |
|---|---|---|
| ?bust=1 on /api/chisel-menu | Single request | Dev/testing after a CMS change |
| CHISEL_CACHE_BYPASS=true env var | Entire deployment | Staging environments |
| revalidateTag("chisel-menu-{clientSlug}") | Server cache | CMS webhook on content publish |
| noCache: true on ProjectMenu | Single render | Debug during development |
// CMS webhook — invalidate server cache on publish
import { revalidateTag } from "next/cache"
revalidateTag("chisel-menu-your-client-slug")
// For a curated menu, the tag includes the menuId
revalidateTag("chisel-menu-your-client-slug-main-nav")Publishing to npm
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.
