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 on the server with zero client-side waterfall requests.
Requirements
- Next.js 13+ (App Router)
- React 18+
No other dependencies required.
Installation
npm install project-portfolioComponents
ProjectPortfolio
A full project grid page. Fetches all projects for a client and renders them as responsive cards (1 column on mobile, 2 on tablet, 3 on desktop).
// app/projects/page.tsx
import { ProjectPortfolio } from "project-portfolio"
// Must be a Server Component — do NOT add "use client"
export default async function ProjectsPage() {
return (
<ProjectPortfolio
clientSlug="your-client-slug"
apiBase="https://your-api.com"
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 |
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 filter integration
ProjectPortfolio works together with ProjectMenu to form a complete filter flow. When a user clicks a filter link in the megamenu, they are navigated to the project grid with filter query params appended to the URL. Pass Next.js searchParams to ProjectPortfolio so it can forward them to the API:
// 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}
/>
)
}Filter URLs follow this pattern — the key matches the custom field key in the schema:
/projects?type=commercial
/projects?type=educational-facilities
/projects?system=spacematicWhen active filters are applied, a filter banner is shown above the grid with a "Clear filters" link back to basePath.
ProjectPortfolioClient
A "use client" component that fetches all projects once on mount (module-level cached — no re-fetch on remount) and filters them locally in memory. It ships with no filter UI — build your own dropdowns, buttons, or search inputs and drive the component with the filters prop.
// 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"
/>
)
}Wire up your own filter UI with the filters prop:
"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>
{/* Component receives filters and applies them in memory */}
<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 |
How filtering works: Pass any Record<string, string> of field key/value pairs and the component filters the already-fetched project list in memory. All filter logic uses AND matching with case-insensitive comparison. The field keys must match the custom field keys in the API schema.
ProjectDetail
A full project detail page. Fetches a single project by slug and renders its gallery, custom fields, related projects, and a back link.
// app/projects/[slug]/page.tsx
import { ProjectDetail } from "project-portfolio"
// Must be a Server Component — do NOT add "use client"
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"
/>
)
}| 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) |
ProjectMenu
A compact megamenu component. Shows featured projects as list-style cards on the left and "Browse By" filter links on the right. Designed to be dropped directly into a navigation megamenu dropdown.
// components/navigation/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. and in a variety of applications."
maxProjects={6}
/>
)
}| 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 |
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 |
The filter options in the right sidebar are driven automatically by the is_filterable fields returned from the API schema — no manual configuration needed.
SimilarProjects
A standalone similar projects section. Fetches all projects for a client, filters to the same type as the current project, and renders up to 3 matching cards. 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"
/>
</>
)
}filters accepts any combination of custom field key/value pairs — all must match (AND logic). excludeSlug optionally removes the current project from results.
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
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) |
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 |
maxItems |
number |
No | 3 |
Maximum number of projects to show |
font |
string |
No | System font stack | Font family string applied to all inline styles |
revalidate |
number |
No | 60 |
Cache revalidation period in seconds |
ProjectMenuClient + createMenuHandler
ProjectMenuClient is a "use client" component that works in any React tree — no RSC, no Suspense wrapper, no server file needed. It fetches its own data and caches it in memory for the lifetime of the page session so the API is never called twice on a hover/re-hover.
There are two ways to use it:
Option 1 — dataUrl + createMenuHandler (recommended for production)
Set up 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",
})// In your nav/header — works in any "use client" component
import { ProjectMenuClient } from "project-portfolio"
<ProjectMenuClient
dataUrl="/api/chisel-menu"
basePath="/projects"
viewAllPath="/projects"
/>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.
import { ProjectMenuClient } from "project-portfolio"
<ProjectMenuClient
clientSlug="your-client-slug"
apiBase="https://your-api.com"
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 |
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. Pre-fetched projects, schema, filterOptions, filterFieldKey, and fieldOptionsMap can also be passed directly for SSR usage.
Important: Server Component Usage
All top-level components (ProjectPortfolio, ProjectDetail, ProjectMenu, SimilarProjects) are async Server Components. They must be rendered in a server context:
// CORRECT
export default async function Page() {
return <ProjectPortfolio clientSlug="..." apiBase="..." />
}
// WRONG — causes infinite client-side fetch loop
"use client"
export default function Page() {
return <ProjectPortfolio clientSlug="..." apiBase="..." />
}If a parent component uses "use client", these components cannot be rendered inside it directly. Pass them as children from a server component instead.
Caching
Data is cached at multiple levels depending on which component is used.
Server Components (ProjectMenu, ProjectPortfolio, ProjectDetail, SimilarProjects)
- Per-render —
React.cache()deduplicates duplicate calls within the same render pass - Cross-request —
next: { revalidate: 86400 }(24 hours) caches responses in Next.js's Data Cache across all users and sessions
ProjectMenuClient with createMenuHandler (recommended)
3. Server-side route cache — the /api/chisel-menu route is cached by Next.js for 24 hours. The upstream API is called at most once per day regardless of traffic
4. Module-level client cache — after the first fetch within a session, the result is held in memory. Re-hovering the menu or remounting the component never triggers another network request
ProjectMenuClient with direct fetch / ProjectPortfolioClient
4. Module-level client cache only — the upstream API is called once per session per user (on first mount), then cached in memory for the rest of that page session. Filter changes on ProjectPortfolioClient never trigger a fetch — filtering is done entirely in memory
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 only | CMS webhook on content publish |
noCache: true on ProjectMenu |
Single render | Debug during development |
// CMS webhook example — invalidate server cache immediately on publish
import { revalidateTag } from "next/cache"
revalidateTag("chisel-menu-your-client-slug")No third-party caching libraries are required.
Publishing to npm
# Log in to npm
npm login
# Build and publish
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.