JSPM

  • ESM via JSPM
  • ES Module Entrypoint
  • Export Map
  • Keywords
  • License
  • Repository URL
  • TypeScript Types
  • README
  • Created
  • Published
  • Downloads 31
  • Score
    100M100P100Q73505F
  • License MIT

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 apiBase — done.

Package Exports

  • project-portfolio

Readme

project-portfolio

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 project-portfolio

Quick 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 "project-portfolio"

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 "project-portfolio"

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 "project-portfolio"

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 "project-portfolio"

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 "project-portfolio"

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-facilities

When 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 "project-portfolio"

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 "project-portfolio"

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 "project-portfolio"

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)

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 "project-portfolio"

// `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 parent component is responsible for injecting the .chisel-gallery-main-img CSS class to control the main image height:

.chisel-gallery-main-img {
  height: 400px; /* adjust to your layout */
}
@media (min-width: 768px) {
  .chisel-gallery-main-img {
    height: 560px;
  }
}
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 "project-portfolio"

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 "project-portfolio"

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"
        />
      )}
    </>
  )
}

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 "project-portfolio"

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"
      />
    </>
  )
}

excludeSlug is still respected even in projectSlugs mode — 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
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)

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 "project-portfolio"

// 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 Browse By filters on the right always reflect the full schema regardless of which menu is active. Available menu IDs can be retrieved from GET /api/v1/clients/{clientSlug}/menus.

<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 ID of a curated menu. When provided, fetches from /menus/{menuId} 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 Disable caching entirely — useful for development

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:


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 "project-portfolio"

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 "project-portfolio"

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 "project-portfolio"

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 ID of a curated menu. Fetches from /menus/{menuId} for projects. Filters are always shown regardless.
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 public

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