npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@crayonscodetech/cms-sdk

v1.0.4

Published

CMS SDK for fetching data and shared types

Readme

@crayonscodetech/cms-sdk

A robust, type-safe SDK/Package for fetching data from the Crayons CMS. Designed for Next.js.

Technical Overview

Data is fetched from the CMS backend at: https://api.cms.deployown.com

Content updates and management are handled through the CMS dashboard:
https://cms.deployown.com

How it Works

  • Headless CMS: This package is purely for data fetching. It provides the raw content (JSON) without any UI or layout constraints.
  • Conditional Rendering: You should fetch the data and use conditional logic to render your components based on the content received.
  • Full Style Control: The backend does not provide CSS or styling. You have total creative freedom to define your own styles and themes within your frontend application.

Features

  • 🛠 Type-safe: Complete TypeScript definitions for all CMS entities.
  • ⚡️ Next.js Optimized: Seamless integration with Next.js fetch (caching, revalidation, tags).
  • 🔄 Resilient: Automatic retries for transient server errors (502, 503, 504).
  • 🧱 Structured: Easy-to-use API for Headers, Footers, Blogs, Events, and more.
  • 🎨 Section Variants: All page sections support optional variant field (e.g., "home-1", "about-2") for flexible conditional styling.

Installation

You can install the SDK directly from private GitHub repository. Ensure you have access before proceeding.

npm install @crayonscodetech/cms-sdk
# or
npm install git+ssh://[email protected]/CrayonsCodeTech/cms-sdk.git
# or
pnpm add git+ssh://[email protected]/CrayonsCodeTech/cms-sdk.git --allow-build=@crayons/cms-sdk
# or
bun add git+ssh://[email protected]/CrayonsCodeTech/cms-sdk.git && bun pm trust @crayons/cms-sdk

Quick Start: Creating a New Next.js App (Cloudflare)

If you are starting a new project, we recommend using the Cloudflare Next.js starter which comes with OpenNext support out of the box.

Run the following command to initialize your app:

npm create cloudflare@latest -- my-next-app --framework=next

For detailed instructions on deploying Next.js to Cloudflare Workers, refer to the official Cloudflare documentation.


Getting Started

1. Environment Variables

Create a .env.local file in your root directory with the following variables:

NEXT_PUBLIC_CMS_BASE_URL=https://api.yourcms.com
NEXT_PUBLIC_CMS_SITE_ID=your-site-id-here

Development Environment

Visit cms.deployown.com/login to view and edit data for the website.

Use the following credentials to log in:

| Field | Value | | -------- | --------------------- | | Username | development | | Password | (provided by admin) |

Add these to your .env.local:

NEXT_PUBLIC_CMS_BASE_URL=https://api.cms.deployown.com
NEXT_PUBLIC_CMS_SITE_ID=30de3c6b-70bd-45dd-a0bd-58143f738902

2. Initialization

It is recommended to create a singleton instance of the CMS client in your project (e.g., lib/cms.ts).

import { createCmsClient } from "@crayons/cms-sdk";

export const cms = createCmsClient({
  baseUrl: process.env.NEXT_PUBLIC_CMS_BASE_URL || "https://api.example.com",
  defaultOptions: {
    revalidate: 3600, // Default 1 hour cache
  },
});

export const SITE_ID = process.env.NEXT_PUBLIC_CMS_SITE_ID || "";

3. Usage in Components

Conditional Rendering — Header & Footer Inner Data

fetchHeader and fetchFooter return null on failure. Inside your components, guard each field individually since arrays may be empty and optional fields may be absent.

SiteHeader example:

// components/site-header.tsx
import type { Header, SiteConfig } from "@crayons/cms-sdk";
import Link from "next/link";

interface Props {
  header: Header;
  siteConfig: SiteConfig | null;
}

export function SiteHeader({ header, siteConfig }: Props) {
  return (
    <nav>
      {/* Logo — use logo.logo_primary (light) or logo.logo_dark as needed */}
      {siteConfig?.logo.logo_primary && (
        <Link href="/">
          <img
            src={siteConfig.logo.logo_primary}
            alt={siteConfig.site_name ?? "Logo"}
          />
        </Link>
      )}

      {/* Nav links — each link may have nested children */}
      {header.nav_links.length > 0 && (
        <ul>
          {header.nav_links.map((link) => (
            <li key={link.url}>
              <Link href={link.url}>{link.title}</Link>

              {/* Dropdown children — only render if they exist */}
              {link.children && link.children.length > 0 && (
                <ul>
                  {link.children.map((child) => (
                    <li key={child.url}>
                      <Link href={child.url}>{child.title}</Link>
                    </li>
                  ))}
                </ul>
              )}
            </li>
          ))}
        </ul>
      )}

      {/* CTAs — map through the array, skip if empty */}
      {header.ctas.length > 0 && (
        <div>
          {header.ctas.map((cta) => (
            <Link key={cta.title_url} href={cta.title_url}>
              {cta.title}
            </Link>
          ))}
        </div>
      )}
    </nav>
  );
}

SiteFooter example:

// components/site-footer.tsx
import type { Footer } from "@crayons/cms-sdk";
import Link from "next/link";

export function SiteFooter({ footer }: { footer: Footer }) {
  return (
    <footer>
      {/* Nav groups — each group has a name and a list of links */}
      {footer.nav_groups.length > 0 && (
        <div>
          {footer.nav_groups.map((group) => (
            <div key={group.name}>
              <h4>{group.name}</h4>

              {group.links.length > 0 && (
                <ul>
                  {group.links.map((link) => (
                    <li key={link.url}>
                      <Link href={link.url}>{link.title}</Link>

                      {/* Nested children under each footer link */}
                      {link.children && link.children.length > 0 && (
                        <ul>
                          {link.children.map((child) => (
                            <li key={child.url}>
                              <Link href={child.url}>{child.title}</Link>
                            </li>
                          ))}
                        </ul>
                      )}
                    </li>
                  ))}
                </ul>
              )}
            </div>
          ))}
        </div>
      )}
    </footer>
  );
}

Using SiteConfig contact & social data in the footer:

SiteConfig also carries the site's contact details and social links — render these directly in the footer rather than hardcoding them.

// Destructure the fields you need from siteConfig
const { site_name, logo, contact } = siteConfig;

// Phone numbers (array)
{
  contact.phone_number.map((phone) => (
    <a key={phone} href={`tel:${phone}`}>
      {phone}
    </a>
  ));
}

// Emails (array)
{
  contact.email.map((email) => (
    <a key={email} href={`mailto:${email}`}>
      {email}
    </a>
  ));
}

// Social links
{
  contact.socials.map((social) => (
    <a
      key={social.site_name}
      href={social.link}
      target="_blank"
      rel="noopener noreferrer"
    >
      {social.site_name}
    </a>
  ));
}

// Location (optional)
{
  contact.location?.location.map((line) => <p key={line}>{line}</p>);
}
{
  contact.location?.google_maps_url && (
    <a
      href={contact.location.google_maps_url}
      target="_blank"
      rel="noopener noreferrer"
    >
      View on Map
    </a>
  );
}

siteConfig can be null if the fetch fails, so always guard it at the layout level and pass it down only if it exists.

4. Icon Component (Lucide)

The SDK provides a built-in Icon component to render CMS-driven icons. It uses lucide-react under the hood.

import { Icon } from "@crayons/cms-sdk";

export function FeatureItem({
  iconName,
  title,
}: {
  iconName: string;
  title: string;
}) {
  return (
    <div>
      {/* Renders the Lucide icon by name, falling back to HelpCircle if not found */}
      <Icon name={iconName} size={24} className="text-primary" />
      <h3>{title}</h3>
    </div>
  );
}

Requirements: To use the Icon component, you must have lucide-react and react installed in your project.


Advanced UI Implementation (Recommended)

For production-grade applications, we recommend a declarative approach using a Registry and Router. This pattern removes the need for hardcoded folders (like /blog or /services) and handles all CMS-driven URLs dynamically.

1. Declarative Page Registry

Map CMS page_type strings to their corresponding React components. This centralizes your UI mapping.

// lib/cms-registry.ts
import HomePage from "@/components/pages/HomePage";
import AboutPage from "@/components/pages/AboutPage";
import BlogsPage from "@/components/pages/BlogPage";
import ServicesPage from "@/components/pages/ServicesPage";
import EventsPage from "@/components/pages/EventPage";
import GalleryPage from "@/components/pages/GalleryPage";
import TeamPage from "@/components/pages/TeamPage";
import ContactPage from "@/components/pages/ContactPage";
import CustomPage from "@/components/pages/CustomPage";
import ProductsPage from "@/components/pages/ProductsPage";

// Detail views (sub-pages)
import BlogDetailPage from "@/components/pages/BlogDetailPage";
import ServiceDetailPage from "@/components/pages/ServiceDetailPage";
import EventDetailPage from "@/components/pages/EventDetailPage";
import GalleryDetailPage from "@/components/pages/GalleryDetailPage";
import TeamMemberDetailPage from "@/components/pages/TeamMemberDetailPage";
import TeamCategoryPage from "@/components/pages/TeamCategoryPage";
import ProductDetailPage from "@/components/pages/ProductDetailPage";

export const PAGE_COMPONENT_MAP: Record<string, any> = {
  home: HomePage,
  about: AboutPage,
  blog: BlogsPage,
  services: ServicesPage,
  events: EventsPage,
  gallery: GalleryPage,
  team: TeamPage,
  contact: ContactPage,
  custom: CustomPage,
  products: ProductsPage,
};

// Maps parent page type to its detail component
export const DETAIL_COMPONENT_MAP: Record<string, any> = {
  blog: BlogDetailPage,
  services: ServiceDetailPage,
  events: EventDetailPage,
  gallery: GalleryDetailPage,
  products: ProductDetailPage,
  team: {
    member: TeamMemberDetailPage,
    category: TeamCategoryPage,
  },
};
2. Route Resolution Helper

This utility determines if a URL path is an exact CMS page or a "Detail" page (e.g., a specific blog post).

// lib/cms-router.ts
import { cms, SITE_ID } from "./cms";

export async function resolveCmsRoute(slug: string[]) {
  const urlPath = slug.length > 0 ? `/${slug.join("/")}` : "/";
  const exactPage = await cms.fetchPageByUrl(SITE_ID, urlPath);

  if (exactPage) return { type: "page" as const, data: exactPage };

  // 2. Check for detail page (walking up the path)
  // Example: /blog/my-post or /news/my-post
  if (slug.length > 0) {
    for (let i = slug.length - 1; i >= 0; i--) {
      const parentPath = "/" + slug.slice(0, i).join("/");
      const parentPage = await cms.fetchPageByUrl(SITE_ID, parentPath || "/");

      if (parentPage) {
        return {
          type: "detail" as const,
          parentType: parentPage.page_type,
          slug: slug.slice(i), // e.g., ["my-post-slug"]
          parentUrl: parentPage.url,
        };
      }
    }
  }

  return null;
}
3. Unified Catch-All Route

Using the registry and router, your app/[[...slug]]/page.tsx handles every route dynamically.

// app/[[...slug]]/page.tsx
import { notFound } from "next/navigation";
import { resolveCmsRoute } from "@/lib/cms-router";
import { PAGE_COMPONENT_MAP, DETAIL_COMPONENT_MAP } from "@/lib/cms-registry";

export default async function CatchAllPage({
  params,
}: {
  params: Promise<{ slug?: string[] }>;
}) {
  const { slug = [] } = await params;
  const resolution = await resolveCmsRoute(slug);

  if (!resolution) notFound();

  if (resolution.type === "page") {
    const Component =
      PAGE_COMPONENT_MAP[resolution.data.page_type] ||
      PAGE_COMPONENT_MAP.custom;
    return <Component page={resolution.data} />;
  }

  if (resolution.type === "detail") {
    const Component = DETAIL_COMPONENT_MAP[resolution.parentType];
    if (!Component) notFound();

    // Special handling for nested detail types (like Team)
    if (resolution.parentType === "team") {
      if (resolution.slug.length === 1) {
        return (
          <Component.category
            params={Promise.resolve({ category: resolution.slug[0] })}
          />
        );
      }
      return (
        <Component.member
          params={Promise.resolve({
            category: resolution.slug[0],
            slug: resolution.slug[1],
          })}
        />
      );
    }

    return (
      <Component
        params={Promise.resolve({ slug: resolution.slug[0] })}
        parentUrl={resolution.parentUrl}
      />
    );
  }

  notFound();
}

Dynamic Page Sections — RenderSections Component

The RenderSections component is the core rendering primitive. It receives page.sections and maps each section type to its component. Every known section type from the CMS is handled; unknown types are warned and skipped.

Important: All section types include an optional variant field (e.g., "home-1", "about-1", "contact-2"). Use this for conditional rendering to create different visual styles of the same section type. See the example below for how to handle variants.

// components/render-sections.tsx
import type { Section } from "@crayons/cms-sdk";

// Import base section components
import { HeroSection } from "@/components/sections/hero";
import { CustomSection } from "@/components/sections/custom";
import { CtaSection } from "@/components/sections/cta";
import { ServiceSection } from "@/components/sections/service";
import { TestimonialSection } from "@/components/sections/testimonial";
import { MultiValueSection } from "@/components/sections/multi-value";
import { TeamSection } from "@/components/sections/team";
import { ClientsSection } from "@/components/sections/clients";
import { GallerySection } from "@/components/sections/gallery";
import { EventSection } from "@/components/sections/event";
import { BlogSection } from "@/components/sections/blog";
import { RichContentSection } from "@/components/sections/rich-content";
import { AboutSection } from "@/components/sections/about";
import { FaqSection } from "@/components/sections/faq";
import { MarqueeSection } from "@/components/sections/marquee";
import { HistorySection } from "@/components/sections/history";
import { ProductsSection } from "@/components/sections/products";
import { CollectionGroupSection } from "@/components/sections/collection-group";

// Import variant components as needed (example imports)
import { HeroDark } from "@/components/sections/hero-dark";
import { HeroCentered } from "@/components/sections/hero-centered";
import { CtaPrimary } from "@/components/sections/cta-primary";

export function RenderSections({ sections }: { sections: Section[] }) {
  return (
    <>
      {sections.map((section) => {
        // Each section has: { id, type, variant?, content }
        // - id: auto-generated identifier (e.g., "hero-1", "custom-2", "cta-3")
        // - type: section type discriminator (e.g., "hero", "custom", "cta")
        // - variant: optional style variant (e.g., "home-1", "home-2", "about-1")
        // - content: the actual content data for that section
        //
        // Example section object:
        // { id: "hero-1", type: "hero", variant: "home-1", content: [...] }
        switch (section.type) {
          case "hero":
            // Variant-aware rendering: check section.variant for conditional styling
            if (section.variant === "home-1") {
              return <HeroDark key={section.id} content={section.content} />;
            }
            if (section.variant === "home-2") {
              return (
                <HeroCentered key={section.id} content={section.content} />
              );
            }
            // Default fallback when variant is undefined/null
            return <HeroSection key={section.id} content={section.content} />;

          case "custom":
            return <CustomSection key={section.id} content={section.content} />;

          case "cta":
            // Example: different CTA styles based on variant
            if (section.variant === "home-1") {
              return <CtaPrimary key={section.id} content={section.content} />;
            }
            return <CtaSection key={section.id} content={section.content} />;

          case "service":
            return (
              <ServiceSection key={section.id} content={section.content} />
            );

          case "testimonial":
            return (
              <TestimonialSection key={section.id} content={section.content} />
            );

          case "multi-value":
            return (
              <MultiValueSection key={section.id} content={section.content} />
            );

          case "team":
            return <TeamSection key={section.id} content={section.content} />;

          case "clients":
            return (
              <ClientsSection key={section.id} content={section.content} />
            );

          case "gallery":
            return (
              <GallerySection key={section.id} content={section.content} />
            );

          case "event":
            return <EventSection key={section.id} content={section.content} />;

          case "blog":
            return <BlogSection key={section.id} content={section.content} />;

          case "rich-content":
            return (
              <RichContentSection key={section.id} content={section.content} />
            );

          case "about":
            return <AboutSection key={section.id} content={section.content} />;

          case "faq":
            return <FaqSection key={section.id} content={section.content} />;

          case "marquee":
            return (
              <MarqueeSection key={section.id} content={section.content} />
            );

          case "history":
            return (
              <HistorySection key={section.id} content={section.content} />
            );

          case "products":
            return (
              <ProductsSection key={section.id} content={section.content} />
            );

          case "collection-group":
            return (
              <CollectionGroupSection
                key={section.id}
                content={section.content}
              />
            );

          default:
            console.warn(`Unknown section type: ${(section as any).type}`);
            return null;
        }
      })}
    </>
  );
}

Note: Each section includes:

  • id: Auto-generated unique identifier in format {type}-{count} (e.g., "hero-1", "hero-2", "custom-1", "cta-3"). Useful for targeting specific sections or debugging.
  • variant: Optional style variant (e.g., "home-1", "home-2", "about-1") for conditional styling.

Always provide a default fallback when section.variant is undefined or null. If your design doesn't use variants, you can simplify the switch cases to just render single components per type. Use section.id when you need to target or reference specific sections programmatically.

Data-Driven Section Components

Several section types only carry display text (headings, subtitles) in section.content. The actual entity data must be fetched separately and passed into the section component. This is the same pattern as services, blogs, and events — just applied inside individual section components.

| Section name | type discriminant | Content type | Primary table/entity | API call(s) needed | | ---------------- | -------------------- | ------------------------ | ----------------------------- | ------------------------------------------------------------------------------------------- | | Hero | "hero" | HeroContent[] | page.sections (from page) | None — content is inline | | Custom | "custom" | CustomContent | page.sections (from page) | None — content is inline | | Call to Action | "cta" | CTAContent | page.sections (from page) | None — content is inline | | Rich Content | "rich-content" | RichContentSection | page.sections (from page) | None — content is inline | | About | "about" | AboutSection | page.sections + about-us | fetchAboutUs(siteId) for profile/vision/mission/stats | | Multi Value | "multi-value" | MultiValueSection | page.sections (from page) | None — content is inline | | Services | "service" | ServicesSection | services | fetchServices(siteId) | | Testimonials | "testimonial" | TestimonialsSection | testimonials | fetchTestimonials(siteId, { type }) — use content.type to filter | | Team | "team" | TeamSection | team-members | fetchTeamMembers(siteId) / fetchTeamMembersByCategory(siteId, { categoryId: content.team_category_id }) | | FAQ | "faq" | FaqSection | faq-groups + faqs | fetchFaqGroups(siteId) or fetchFaqs(siteId, { group_id: content.group_id }) | | Clients / Brands | "clients" | ClientsSection | brand-groups + brands | fetchBrandGroups(siteId) + fetchBrands(siteId, { group_id: content.brand_group_id }) | | Gallery | "gallery" | GallerySection | albums + album-items | fetchAlbums(siteId) + fetchAlbumItems(siteId, { album_id }) as needed | | Events | "event" | GenericSection | events | fetchEvents(siteId, { page, limit, search }) | | Blog | "blog" | GenericSection | blog | fetchBlogs(siteId, { page, limit, search }) | | Products | "products" | ProductsSection | products / collections | fetchProducts(...) or fetchCollectionDetailById(...), depending on content.filter | | Collection Group | "collection-group" | CollectionGroupSection | collections | fetchCollections(siteId, { id: content.collection_groups.join(',') }) | | Marquee | "marquee" | MarqueeSection | page.sections (from page) | None — content is inline | | History | "history" | HistorySection | page.sections (from page) | None — content is inline |

How to handle this in section components:

Each section component receives its own content prop from RenderSections. When it needs live entity data, it fetches it itself using the ID or type from content.

For the new store-aware sections:

  • products content uses filter plus one of collection_id, category_id, or tag_id, along with limit
  • collection-group content uses collection_groups: string[]
// components/sections/testimonial.tsx
import { cms, SITE_ID } from "@/lib/cms";
import type { TestimonialsSection } from "@crayons/cms-sdk";

interface Props {
  content: TestimonialsSection;
}

export async function TestimonialSection({ content }: Props) {
  // content.type is "testimonial" | "review" — use it to filter
  const { data: testimonials } = await cms.fetchTestimonials(SITE_ID, {
    type: content.type as "testimonial" | "review",
  });

  return (
    <section>
      <h2>{content.title}</h2>
      {content.subtitle && <p>{content.subtitle}</p>}

      {testimonials.map((t) => (
        <blockquote key={t.id}>
          {t.image_url && <img src={t.image_url} alt={t.image_alt ?? t.name} />}
          <p>{t.quote}</p>
          <cite>
            {t.name}
            {t.position && `, ${t.position}`}
            {t.company && ` — ${t.company}`}
          </cite>
        </blockquote>
      ))}
    </section>
  );
}
// components/sections/team.tsx
import { cms, SITE_ID } from "@/lib/cms";
import type { TeamSection } from "@crayons/cms-sdk";

export async function TeamSection({ content }: { content: TeamSection }) {
  const { data: members } = await cms.fetchTeamMembers(SITE_ID);

  return (
    <section>
      <h2>{content.title}</h2>
      {content.subtitle && <p>{content.subtitle}</p>}

      <div className="grid">
        {members.map((member) => (
          <div key={member.id}>
            {member.profile_image && (
              <img src={member.profile_image} alt={member.name} />
            )}
            <h3>{member.name}</h3>
            {member.position && <p>{member.position}</p>}
            {member.socials && member.socials.length > 0 && (
              <ul>
                {member.socials.map(
                  (s) =>
                    s.url && (
                      <li key={s.platform}>
                        <a
                          href={s.url}
                          target="_blank"
                          rel="noopener noreferrer"
                        >
                          {s.platform}
                        </a>
                      </li>
                    ),
                )}
              </ul>
            )}
          </div>
        ))}
      </div>
    </section>
  );
}
// components/sections/faq.tsx
import { cms, SITE_ID } from "@/lib/cms";
import type { FaqSection } from "@crayons/cms-sdk";

export async function FaqSection({ content }: { content: FaqSection }) {
  // Fetch the specific group if a group_id is set, otherwise fetch all
  const { data: groups } = await cms.fetchFaqGroups(SITE_ID);

  const targetGroup = content.group_id
    ? groups.find((g) => g.id === content.group_id)
    : null;

  const faqs = targetGroup
    ? targetGroup.faqs
    : (await cms.fetchFaqs(SITE_ID)).data;

  return (
    <section>
      <h2>{content.title}</h2>
      {content.subtitle && <p>{content.subtitle}</p>}

      <dl>
        {faqs.map((faq) => (
          <div key={faq.id}>
            <dt>{faq.question}</dt>
            <dd>{faq.answer}</dd>
          </div>
        ))}
      </dl>
    </section>
  );
}

Section components that fetch their own data must be async server components. This works because RenderSections itself is also a server component — you can await inside any section component freely.


Page Architecture

Different page types follow different rendering strategies. Understanding these patterns is key to building correctly.

Page Fetch Map (Route -> Table/Entity -> SDK Fetch)

| Route | Primary tables/entities | Required fetch call(s) | | ----------------------- | --------------------------------- | ----------------------------------------------------------------------------------------------- | | / (home) | page (+ inline page.sections) | fetchPageByUrl(siteId, "/") | | [[...slug]] CMS pages | page (+ inline page.sections) | fetchPageByUrl(siteId, urlPath) | | /about | page + about-us | fetchPageByUrl(siteId, "/about") + fetchAboutUs(siteId) | | /services | page + services | fetchPageByUrl(siteId, "/services") + fetchServices(siteId) | | /services/[slug] | services | fetchServices(siteId) (slug lookup) or fetchServiceById(siteId, id) | | /blog or /news | page + blog | fetchPageByUrl(siteId, "/blog") (or your CMS-defined base url) + fetchBlogs(siteId, params) | | /blog/[slug] | blog | fetchBlogBySlug(siteId, slug) | | /events | page + events | fetchPageByUrl(siteId, "/events") + fetchEvents(siteId, params) | | /events/[slug] | events | fetchEvents(siteId, { limit }) (slug lookup) or fetchEventById(siteId, id) | | /gallery | page + albums | fetchPageByUrl(siteId, "/gallery") + fetchAlbums(siteId, params) | | /gallery/[slug] | albums + album-items | fetchAlbums(siteId, { limit }) + fetchAlbumItems(siteId, { album: slug }) | | /team/[slug] | team-members | fetchTeamMembers(siteId) (slug lookup) or custom fetch | | /contact | contact (form submissions) | submitContactForm(siteId, payload) |

Home Page — Section Rendering with Targeting

The home page is a CMS-managed page (page_type: "home"). It uses RenderSections to render its sections in order. However, because the home page often needs precise control over layout (e.g. placing a specific section above the fold, or inserting non-CMS UI between sections), you can target sections by type + index or by section id instead of blindly rendering all sections in sequence.

Option A — target by section type and index:

// components/pages/HomePage.tsx
import { cms, SITE_ID } from "@/lib/cms";
import { notFound } from "next/navigation";
import { HeroSection } from "@/components/sections/hero";
import { RenderSections } from "@/components/render-sections";
import type { Page } from "@crayons/cms-sdk";

export default async function HomePage({ page }: { page: Page }) {
  const { sections } = page;

  // Pull specific sections out by type for precise placement
  const heroSections = sections.filter((s) => s.type === "hero");
  const remainingSections = sections.filter((s) => s.type !== "hero");

  return (
    <>
      {/* Render the first hero section at the top of the page */}
      {heroSections[0] && <HeroSection content={heroSections[0].content} />}

      {/* Your own custom UI can go here between sections */}

      {/* Render the rest of the sections in CMS order */}
      <RenderSections sections={remainingSections} />
    </>
  );
}

Option B — target by section id:

// Pull a specific section by its id (visible in the CMS dashboard)
const featuredSection = sections.find((s) => s.id === "your-section-id");
const otherSections = sections.filter((s) => s.id !== "your-section-id");

For most home pages, just rendering all sections with <RenderSections sections={page.sections} /> in CMS order is the simplest and correct approach. Only reach for targeting when the design requires it.


Custom Pages — Render Sections in Order

Pages with page_type: "custom" (e.g. About, Pricing, any landing page) are fully CMS-driven. Render their sections exactly as they come — no targeting or special logic needed.

// This is handled automatically by the [[...slug]] catch-all route.
// The page component just passes sections straight through:
return <RenderSections sections={page.sections} />;

The CMS editor controls the order and content of all sections. Your job is to make sure every section type is handled in RenderSections.


About Us Page — Page Sections + About Data

About pages usually combine:

  • Page-managed section chrome (section_heading, title, CTA labels) from fetchPageByUrl("/about")
  • Actual about content (company profile, vision, mission, stats, values) from fetchAboutUs
// components/pages/AboutPage.tsx
import { cms, SITE_ID } from "@/lib/cms";
import { notFound } from "next/navigation";
import SafeHtml from "@/components/safe-html";
import type { Page, SiteConfig } from "@crayons/cms-sdk";

export default async function AboutPage({
  page,
  site,
}: {
  page: Page;
  site?: SiteConfig | null;
}) {
  const about = await cms.fetchAboutUs(SITE_ID);
  if (!about) notFound();

  const aboutSection = page.sections.find((s) => s.type === "about");

  return (
    <main>
      {aboutSection && (
        <header>
          <p>{aboutSection.content.section_heading}</p>
          <h1>{aboutSection.content.title}</h1>
        </header>
      )}

      <section>
        <h2>Company Profile</h2>
        <SafeHtml html={about.company_profile} />
      </section>

      <section>
        <h2>Vision</h2>
        <SafeHtml html={about.vision} />
      </section>

      <section>
        <h2>Mission</h2>
        <SafeHtml html={about.mission} />
      </section>

      {about.values.length > 0 && (
        <section>
          <h2>Core Values</h2>
          <ul>
            {about.values.map((value) => (
              <li key={value.title}>
                <h3>{value.title}</h3>
                <p>{value.description}</p>
              </li>
            ))}
          </ul>
        </section>
      )}
    </main>
  );
}

Services Page — Fetch & Render Service Data

The service section type on a page provides only CMS-controlled headings and labels (e.g. section_heading, title, subtitle). The actual list of services must be fetched separately with fetchServices.

// components/pages/ServicesPage.tsx
import { cms, SITE_ID } from "@/lib/cms";
import { notFound } from "next/navigation";
import { ServiceCard } from "@/components/service-card";
import type { Page } from "@crayons/cms-sdk";

export default async function ServicesPage({ page }: { page: Page }) {
  const services = await cms.fetchServices(SITE_ID);

  // The "service" section from the page carries the heading/subtitle
  const serviceSection = page.sections.find((s) => s.type === "service");

  return (
    <>
      {serviceSection && (
        <header>
          <h1>{serviceSection.content.title}</h1>
          {serviceSection.content.subtitle && (
            <p>{serviceSection.content.subtitle}</p>
          )}
        </header>
      )}

      <div className="grid">
        {services.map((service) => (
          <ServiceCard key={service.id} service={service} />
        ))}
      </div>
    </>
  );
}

Service detail page:

// components/pages/ServiceDetailPage.tsx
import { cms, SITE_ID } from "@/lib/cms";
import { notFound } from "next/navigation";
import { RenderSections } from "@/components/render-sections";

export default async function ServiceDetailPage({
  params,
  parentUrl,
}: {
  params: Promise<{ slug: string }>;
  parentUrl?: string;
}) {
  const { slug } = await params;
  const services = await cms.fetchServices(SITE_ID);
  const service = services.find((s) => s.slug === slug);

  if (!service) notFound();

  return (
    <article>
      {service.image_url && (
        <img src={service.image_url} alt={service.image_alt} />
      )}
      <h1>{service.title}</h1>
      <p>{service.short_description}</p>
      <div dangerouslySetInnerHTML={{ __html: service.description }} />
      {service.features.length > 0 && (
        <ul>
          {service.features.map((f) => (
            <li key={f}>{f}</li>
          ))}
        </ul>
      )}

      {/* Render sections from extra.sections if present */}
      {service.extra?.sections && service.extra.sections.length > 0 && (
        <RenderSections sections={service.extra.sections} />
      )}
    </article>
  );
}

Note: Services can have custom sections stored in extra.sections. Use <RenderSections /> to render them on the detail page. This is optional — if no sections are defined, the service renders normally as shown above.


Blog Page — Listing & Detail

The blog section type carries heading/subtitle text only. Fetch the actual posts with fetchBlogs.

Listing page:

// components/pages/BlogPage.tsx
import { cms, SITE_ID } from "@/lib/cms";
import { notFound } from "next/navigation";
import Link from "next/link";
import type { Page } from "@crayons/cms-sdk";

export default async function BlogPage({ page }: { page: Page }) {
  const { data: blogs } = await cms.fetchBlogs(SITE_ID, { page: 1, limit: 12 });

  const blogSection = page.sections.find((s) => s.type === "blog");

  return (
    <>
      {blogSection && <h1>{blogSection.content.title}</h1>}

      <div className="grid">
        {blogs.map((blog) => (
          <Link key={blog.id} href={`/blog/${blog.slug}`}>
            {blog.image_url && (
              <img src={blog.image_url} alt={blog.image_alt ?? blog.title} />
            )}
            <h2>{blog.title}</h2>
            {blog.excerpt && <p>{blog.excerpt}</p>}
          </Link>
        ))}
      </div>
    </>
  );
}

Search and category filtering:

// Search — pass the query as a param
const { data: results } = await cms.fetchBlogs(SITE_ID, {
  search: searchQuery,
});

// Category filtering — fetch categories then filter client-side, or show per-category pages
const { data: categories } = await cms.fetchCategories(SITE_ID);

// Blogs don't have a direct category_id param — fetch categories for display,
// then use them as navigation labels linking to filtered URLs

Blog detail page:

// components/pages/BlogDetailPage.tsx
import { cms, SITE_ID } from "@/lib/cms";
import { notFound } from "next/navigation";
import { RenderSections } from "@/components/render-sections";

export default async function BlogDetailPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const full = await cms.fetchBlogBySlug(SITE_ID, slug);
  if (!full) notFound();

  return (
    <article>
      {full.image_url && (
        <img src={full.image_url} alt={full.image_alt ?? full.title} />
      )}
      <h1>{full.title}</h1>
      <p>By {full.author}</p>
      {full.description && (
        <div dangerouslySetInnerHTML={{ __html: full.description }} />
      )}

      {/* Render sections from extra.sections if present */}
      {full.extra?.sections && full.extra.sections.length > 0 && (
        <RenderSections sections={full.extra.sections} />
      )}
    </article>
  );
}

Note: Blogs can have custom sections stored in extra.sections. Use <RenderSections /> to render them on the detail page. This is optional — if no sections are defined, the blog renders normally as shown above.


Events Page — Listing & Detail

Same pattern as blogs. The event section carries display text; actual event data comes from fetchEvents.

Listing page:

// components/pages/EventPage.tsx
import { cms, SITE_ID } from "@/lib/cms";
import { notFound } from "next/navigation";
import Link from "next/link";
import type { Page, SiteConfig } from "@crayons/cms-sdk";

export default async function EventsPage({
  page,
  site,
}: {
  page: Page;
  site?: SiteConfig | null;
}) {
  const { data: events } = await cms.fetchEvents(SITE_ID, {
    page: 1,
    limit: 12,
  });

  return (
    <div>
      {events.map((event) => (
        <Link key={event.id} href={`/events/${event.slug}`}>
          {event.image_url && (
            <img src={event.image_url} alt={event.image_alt ?? event.title} />
          )}
          <h2>{event.title}</h2>
          <time>{event.start_date}</time>
          {event.location_name && <p>{event.location_name}</p>}
          {event.excerpt && <p>{event.excerpt}</p>}
        </Link>
      ))}
    </div>
  );
}

Event detail page:

// components/pages/EventDetailPage.tsx
import { cms, SITE_ID } from "@/lib/cms";
import { notFound } from "next/navigation";
import { RenderSections } from "@/components/render-sections";

export default async function EventDetailPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const { data: events } = await cms.fetchEvents(SITE_ID, { limit: 1000 });
  const event = events.find((e) => e.slug === slug);

  if (!event) notFound();

  return (
    <article>
      {event.image_url && (
        <img src={event.image_url} alt={event.image_alt ?? event.title} />
      )}
      <h1>{event.title}</h1>
      <time>{event.start_date}</time>
      {event.end_date && <time> – {event.end_date}</time>}
      {event.location_name && <p>{event.location_name}</p>}
      {event.address && <address>{event.address}</address>}
      {event.description && (
        <div dangerouslySetInnerHTML={{ __html: event.description }} />
      )}

      {/* Render sections from extra.sections if present */}
      {event.extra?.sections && event.extra.sections.length > 0 && (
        <RenderSections sections={event.extra.sections} />
      )}
    </article>
  );
}

Note: Events can have custom sections stored in extra.sections. Use <RenderSections /> to render them on the detail page. This is optional — if no sections are defined, the event renders normally as shown above.


Gallery Page — Albums & Photos

The gallery section carries the album_id to display. Fetch albums with fetchAlbums; each album includes its items (photos).

// components/pages/GalleryPage.tsx
import { cms, SITE_ID } from "@/lib/cms";
import { notFound } from "next/navigation";
import Link from "next/link";
import type { Page } from "@crayons/cms-sdk";

export default async function GalleryPage({ page }: { page: Page }) {
  const { data: albums } = await cms.fetchAlbums(SITE_ID);

  return (
    <div className="grid">
      {albums.map((album) => (
        <Link key={album.id} href={`/gallery/${album.slug}`}>
          {album.cover_image_url && (
            <img
              src={album.cover_image_url}
              alt={album.cover_image_alt ?? album.title}
            />
          )}
          <h2>{album.title}</h2>
          {album.description && <p>{album.description}</p>}
        </Link>
      ))}
    </div>
  );
}

Album detail page:

// components/pages/GalleryDetailPage.tsx
import { cms, SITE_ID } from "@/lib/cms";
import { notFound } from "next/navigation";

export default async function AlbumDetailPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const { data: albums } = await cms.fetchAlbums(SITE_ID);
  const album = albums.find((a) => a.slug === slug);

  if (!album) notFound();

  return (
    <div>
      <h1>{album.title}</h1>
      {album.description && <p>{album.description}</p>}

      <div className="grid">
        {album.items?.map((item) => (
          <figure key={item.id}>
            <img src={item.image_url} alt={item.image_alt ?? ""} />
            {item.caption && <figcaption>{item.caption}</figcaption>}
          </figure>
        ))}
      </div>
    </div>
  );
}

Contact Page — Form Only, No Section Rendering

The contact page does not use RenderSections. It is a dedicated form page that submits directly to the CMS via submitContactForm. Do not render CMS sections here — just build your form UI and wire it to the SDK.

// components/pages/ContactPage.tsx
import { ContactForm } from "@/components/contact-form";
import type { Page, SiteConfig } from "@crayons/cms-sdk";

export default function ContactPage({
  page,
  site,
}: {
  page: Page;
  site?: SiteConfig | null;
}) {
  return (
    <main>
      <h1>Contact Us</h1>
      <ContactForm />
    </main>
  );
}
// components/contact-form.tsx  (client component — handles submission)
"use client";

import { useState } from "react";
import type { ContactPayload } from "@crayons/cms-sdk";

export function ContactForm() {
  const [status, setStatus] = useState<
    "idle" | "sending" | "success" | "error"
  >("idle");

  async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    setStatus("sending");

    const form = e.currentTarget;
    const payload: ContactPayload = {
      name: (form.elements.namedItem("name") as HTMLInputElement).value,
      email: (form.elements.namedItem("email") as HTMLInputElement).value,
      subject: (form.elements.namedItem("subject") as HTMLInputElement).value,
      message: (form.elements.namedItem("message") as HTMLTextAreaElement)
        .value,
      type: "contact",
    };

    try {
      // submitContactForm is called from a server action or API route to keep SITE_ID server-side
      const res = await fetch("/api/contact", {
        method: "POST",
        body: JSON.stringify(payload),
        headers: { "Content-Type": "application/json" },
      });

      setStatus(res.ok ? "success" : "error");
    } catch {
      setStatus("error");
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <input name="name" placeholder="Name" required />
      <input name="email" type="email" placeholder="Email" />
      <input name="subject" placeholder="Subject" />
      <textarea name="message" placeholder="Message" required />
      <button type="submit" disabled={status === "sending"}>
        {status === "sending" ? "Sending…" : "Send"}
      </button>
      {status === "success" && <p>Message sent!</p>}
      {status === "error" && <p>Something went wrong. Please try again.</p>}
    </form>
  );
}
// app/api/contact/route.ts  (server — keeps SITE_ID out of the client bundle)
import { cms, SITE_ID } from "@/lib/cms";
import type { ContactPayload } from "@crayons/cms-sdk";

export async function POST(req: Request) {
  const payload: ContactPayload = await req.json();
  const result = await cms.submitContactForm(SITE_ID, payload);
  return Response.json(result);
}

SITE_ID is kept server-side. Never call submitContactForm directly from a client component.


Store

The store is a separate product/e-commerce layer built on top of the CMS. It uses a completely different API prefix (/api/public/store/) and its routes are hardcoded in the catch-all — they are not CMS-managed pages.

Overview

| Concern | CMS | Store | | ---------------- | --------------------------- | ----------------------------- | | API prefix | /api/public/cms/{siteId}/ | /api/public/store/{siteId}/ | | Route management | CMS dashboard (page_type) | Hardcoded in [[...slug]] | | Content editing | Via CMS | Via store admin |

Feature flag — gate all store UI behind a constant so it can be disabled per project:

// config/store.ts
export const STORE_ENABLED = true;

Store Types

| File | Exports | | --------------------- | --------------------------------------------------------------------------------------- | | product.ts | Product, ProductVariant, ProductImage, ProductStatus | | product-category.ts | ProductCategory | | product-brand.ts | ProductBrand | | collection.ts | Collection, CollectionDetail, CollectionItem | | order.ts | Order, OrderItem, ShippingAddress, PlaceOrderPayload, CartItem, OrderStatus | | seo.ts | ProductSEO, ProductExtraData |

import type {
  Product,
  ProductVariant,
  ProductCategory,
  ProductBrand,
  Collection,
  CollectionDetail,
  CollectionItem,
  Order,
  PlaceOrderPayload,
  CartItem,
  ProductSEO,
} from "@crayons/cms-sdk";

SEO and Extra Fields

The following types include seo and extra fields:

  • Product
  • ProductListItem
  • ProductCategory
  • ProductBrand
  • Collection / CollectionListItem

The ProductSEO interface contains:

interface ProductSEO {
  title?: string | null;
  description?: string | null;
  tags?: string[] | null;
}

ProductExtraData is a flexible Record<string, unknown> for custom data.

Product.description is HTML — render with dangerouslySetInnerHTML. Public product variants expose inventory as a boolean plus low_stock. Collection detail responses normalize both manual and smart collections into collection.items.


Route Structure

Store routes are top-level and handled before CMS page resolution in [[...slug]]/page.tsx:

/products               → product listing
/products/[slug]        → product detail
/categories             → all categories
/categories/[slug]      → products filtered by category
/brands                 → all brands
/brands/[slug]          → products filtered by brand
/collections            → all collections
/collections/[slug]     → collection detail + its products

Catch-all integration — handle store routes first:

// app/[[...slug]]/page.tsx
import { STORE_ENABLED } from "@/config/store";
import ProductsPage from "@/components/pages/ProductsPage";
import ProductDetailPage from "@/components/pages/ProductDetailPage";
import ProductCategoriesPage from "@/components/pages/ProductCategoriesPage";
import CategoryProductsPage from "@/components/pages/CategoryProductsPage";
import BrandsPage from "@/components/pages/BrandsPage";
import BrandProductsPage from "@/components/pages/BrandProductsPage";
import CollectionsPage from "@/components/pages/CollectionsPage";
import CollectionDetailPage from "@/components/pages/CollectionDetailPage";

export default async function CatchAll({ params, searchParams }) {
  const { slug = [] } = await params;

  // ── Store routes (resolved before CMS pages) ──────────────────────────────
  if (
    !STORE_ENABLED &&
    ["products", "categories", "brands", "collections"].includes(slug[0])
  ) {
    notFound();
  }

  if (slug[0] === "products") {
    if (slug.length === 1) return <ProductsPage searchParams={searchParams} />;
    return <ProductDetailPage params={Promise.resolve({ slug: slug[1] })} />;
  }

  if (slug[0] === "categories") {
    if (slug.length === 1) return <ProductCategoriesPage />;
    return (
      <CategoryProductsPage
        params={Promise.resolve({ slug: slug[1] })}
        searchParams={searchParams}
      />
    );
  }

  if (slug[0] === "brands") {
    if (slug.length === 1) return <BrandsPage />;
    return (
      <BrandProductsPage
        params={Promise.resolve({ slug: slug[1] })}
        searchParams={searchParams}
      />
    );
  }

  if (slug[0] === "collections") {
    if (slug.length === 1) return <CollectionsPage />;
    return <CollectionDetailPage params={Promise.resolve({ slug: slug[1] })} />;
  }

  // ── CMS pages (catch-all continues below) ────────────────────────────────
  // ... resolveCmsRoute / fetchPageByUrl logic
}

Products Page

// components/pages/ProductsPage.tsx
import { cms, SITE_ID } from "@/lib/cms";
import type { Product, ProductCategory } from "@crayons/cms-sdk";
import Link from "next/link";

interface Props {
  searchParams: Promise<{
    page?: string;
    search?: string;
    category_id?: string;
  }>;
}

export default async function ProductsPage({ searchParams }: Props) {
  const { page = "1", search, category_id } = await searchParams;

  const [{ data: products, pagination }, categories] = await Promise.all([
    cms.fetchProducts(SITE_ID, {
      page: Number(page),
      limit: 12,
      search,
      category_id,
    }),
    cms.fetchProductCategories(SITE_ID),
  ]);

  return (
    <div>
      {/* Category filter tabs */}
      <nav>
        <Link href="/products">All</Link>
        {categories.map((cat) => (
          <Link key={cat.id} href={`/products?category_id=${cat.id}`}>
            {cat.name}
          </Link>
        ))}
      </nav>

      {/* Product grid */}
      <div className="grid">
        {products.map((product) => (
          <Link key={product.id} href={`/products/${product.slug}`}>
            {product.thumbnail_url && (
              <img src={product.thumbnail_url} alt={product.name} />
            )}
            <h2>{product.name}</h2>
            {product.subtitle && <p>{product.subtitle}</p>}
            {product.is_featured && <span>Featured</span>}
          </Link>
        ))}
      </div>

      {/* Pagination */}
      <p>
        Page {pagination.page} • Total products: {pagination.total}
      </p>
    </div>
  );
}

Product Detail Page

The detail page is split into a server component (data fetch) and a client component (interactivity — variant selection, cart). variants is only returned by fetchProductDetail, not the list endpoint.

// components/pages/ProductDetailPage.tsx  (server component)
import { cms, SITE_ID } from "@/lib/cms";
import { notFound } from "next/navigation";
import ProductDetailClient from "@/components/store/ProductDetailClient";

export default async function ProductDetailPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const product = await cms.fetchProductDetail(SITE_ID, slug);

  if (!product) notFound();

  return <ProductDetailClient product={product} />;
}
// components/store/ProductDetailClient.tsx  (client component)
"use client";

import { useState } from "react";
import type { Product, ProductVariant } from "@crayons/cms-sdk";
import { useCart } from "@/context/CartContext";

export default function ProductDetailClient({ product }: { product: Product }) {
  const { addItem } = useCart();
  const [selectedVariant, setSelectedVariant] = useState<ProductVariant | null>(
    product.variants?.[0] ?? null,
  );
  const [quantity, setQuantity] = useState(1);

  function handleAddToCart() {
    if (!selectedVariant) return;
    addItem(product, selectedVariant, quantity);
  }

  return (
    <article>
      {product.thumbnail_url && (
        <img src={product.thumbnail_url} alt={product.name} />
      )}
      <h1>{product.name}</h1>
      {product.subtitle && <p>{product.subtitle}</p>}

      {/* Variant selector */}
      {product.variants && product.variants.length > 0 && (
        <div>
          {product.variants.map((v) => (
            <button
              key={v.id}
              onClick={() => setSelectedVariant(v)}
              aria-pressed={selectedVariant?.id === v.id}
            >
              {v.name ?? v.sku} — ${v.sale_price ?? v.price}
              {v.inventory === 0 && " (Out of stock)"}
            </button>
          ))}
        </div>
      )}

      {/* Quantity + add to cart */}
      <div>
        <button onClick={() => setQuantity((q) => Math.max(1, q - 1))}>
          -
        </button>
        <span>{quantity}</span>
        <button onClick={() => setQuantity((q) => q + 1)}>+</button>
      </div>
      <button
        onClick={handleAddToCart}
        disabled={!selectedVariant || selectedVariant.inventory === 0}
      >
        Add to Cart
      </button>

      {/* Rich-text description */}
      <div dangerouslySetInnerHTML={{ __html: product.description }} />

      {/* Variant specs */}
      {selectedVariant?.specifications &&
        Object.entries(selectedVariant.specifications).map(([group, specs]) => (
          <div key={group}>
            <h3>{group}</h3>
            {Object.entries(specs).map(([k, v]) => (
              <p key={k}>
                <strong>{k}:</strong> {v}
              </p>
            ))}
          </div>
        ))}
    </article>
  );
}

Categories Page

// components/pages/ProductCategoriesPage.tsx
import { cms, SITE_ID } from "@/lib/cms";
import Link from "next/link";

export default async function ProductCategoriesPage() {
  const categories = await cms.fetchProductCategories(SITE_ID);

  return (
    <div className="grid">
      {categories.map((cat) => (
        <Link key={cat.id} href={`/categories/${cat.slug}`}>
          {cat.image_url && <img src={cat.image_url} alt={cat.name} />}
          <h2>{cat.name}</h2>
          {cat.description && <p>{cat.description}</p>}
        </Link>
      ))}
    </div>
  );
}

Category detail page — products filtered by category:

// components/pages/CategoryProductsPage.tsx
import { cms, SITE_ID } from "@/lib/cms";
import { notFound } from "next/navigation";
import Link from "next/link";

interface Props {
  params: Promise<{ slug: string }>;
  searchParams: Promise<{ page?: string; search?: string }>;
}

export default async function CategoryProductsPage({
  params,
  searchParams,
}: Props) {
  const { slug } = await params;
  const { page = "1", search } = await searchParams;

  // Resolve category_id from slug
  const categories = await cms.fetchProductCategories(SITE_ID);
  const category = categories.find((c) => c.slug === slug);
  if (!category) notFound();

  const { data: products, pagination } = await cms.fetchProducts(SITE_ID, {
    category_id: category.id,
    page: Number(page),
    limit: 12,
    search,
  });

  return (
    <div>
      <h1>{category.name}</h1>
      {category.description && <p>{category.description}</p>}

      <div className="grid">
        {products.map((product) => (
          <Link key={product.id} href={`/products/${product.slug}`}>
            {product.thumbnail_url && (
              <img src={product.thumbnail_url} alt={product.name} />
            )}
            <h2>{product.name}</h2>
          </Link>
        ))}
      </div>
    </div>
  );
}

Brands Page

// components/pages/BrandsPage.tsx
import { cms, SITE_ID } from "@/lib/cms";
import Link from "next/link";

export default async function BrandsPage() {
  const brands = await cms.fetchProductBrands(SITE_ID);

  return (
    <div className="grid">
      {brands.map((brand) => (
        <Link key={brand.id} href={`/brands/${brand.slug}`}>
          {brand.logo_url && <img src={brand.logo_url} alt={brand.name} />}
          <h2>{brand.name}</h2>
          {brand.description && <p>{brand.description}</p>}
        </Link>
      ))}
    </div>
  );
}

Brand detail page — products filtered by brand:

// components/pages/BrandProductsPage.tsx
import { cms, SITE_ID } from "@/lib/cms";
import { notFound } from "next/navigation";
import Link from "next/link";

interface Props {
  params: Promise<{ slug: string }>;
  searchParams: Promise<{ page?: string; search?: string }>;
}

export default async function BrandProductsPage({
  params,
  searchParams,
}: Props) {
  const { slug } = await params;
  const { page = "1", search } = await searchParams;

  const brands = await cms.fetchProductBrands(SITE_ID);
  const brand = brands.find((b) => b.slug === slug);
  if (!brand) notFound();

  const { data: products, pagination } = await cms.fetchProducts(SITE_ID, {
    brand_id: brand.id,
    page: Number(page),
    limit: 12,
    search,
  });

  return (
    <div>
      {brand.logo_url && <img src={brand.logo_url} alt={brand.name} />}
      <h1>{brand.name}</h1>

      <div className="grid">
        {products.map((product) => (
          <Link key={product.id} href={`/products/${product.slug}`}>
            {product.thumbnail_url && (
              <img src={product.thumbnail_url} alt={product.name} />
            )}
            <h2>{product.name}</h2>
          </Link>
        ))}
      </div>
    </div>
  );
}

Collections Page

// components/pages/CollectionsPage.tsx
import { cms, SITE_ID } from "@/lib/cms";
import Link from "next/link";

export default async function CollectionsPage() {
  const collections = await cms.fetchCollections(SITE_ID);

  return (
    <div className="grid">
      {collections.map((col) => (
        <Link key={col.id} href={`/collections/${col.slug}`}>
          <h2>{col.name}</h2>
          {col.description && <p>{col.description}</p>}
          {col._count && <span>{col._count.items} products</span>}
        </Link>
      ))}
    </div>
  );
}

Collection detail — renders the collection's products in order:

// components/pages/CollectionDetailPage.tsx
import { cms, SITE_ID } from "@/lib/cms";
import { notFound } from "next/navigation";
import Link from "next/link";

export default async function CollectionDetailPage({
  params,
  searchParams,
}: {
  params: Promise<{ slug: string }>;
  searchParams: Promise<{ category_id?: string; page?: string }>;
}) {
  const { slug } = await params;
  const { category_id, page = "1" } = await searchParams;
  const collection = await cms.fetchCollectionDetail(SITE_ID, slug, {
    category_id,
    page: Number(page),
    limit: 20,
  });

  if (!collection) notFound();

  // Works for both manual collections and smart collections
  const items = collection.items ?? [];

  return (
    <div>
      <h1>{collection.name}</h1>
      {collection.description && <p>{collection.description}</p>}
      {collection.collection_type === "smart" && (
        <p>This collection is populated automatically.</p>
      )}

      <div className="grid">
        {items.map((item) => (
          <Link key={item.id} href={`/products/${item.product.slug}`}>
            {item.product.thumbnail_url && (
              <img src={item.product.thumbnail_url} alt={item.product.name} />
            )}
            <h2>{item.product.name}</h2>
            {/* Show lowest variant price */}
            {item.product.variants.length > 0 && (
              <p>
                From $
                {Math.min(
                  ...item.product.variants.map((v) => v.sale_price ?? v.price),
                )}
              </p>
            )}
          </Link>
        ))}
      </div>

      {collection.pagination && (
        <p>
          Page {collection.pagination.page} • Total matched products:{" "}
          {collection.pagination.total}
        </p>
      )}
    </div>
  );
}

collection.items includes the full product object with variants for both manual and smart collections. The backend resolves smart collections before returning the public detail payload.


Cart State (CartContext)

The cart is managed client-side using React Context with localStorage persistence. Wrap the root layout with the provider.

// context/CartContext.tsx
"use client";

import { createContext, useContext, useState, useEffect } from "react";
import type { CartItem, Product, ProductVariant } from "@crayons/cms-sdk";

interface CartContextValue {
  items: CartItem[];
  totalItems: number;
  subtotal: number;
  isOpen: boolean;
  addItem: (
    product: Product,
    variant: ProductVariant,
    quantity: number,
  ) => void;
  removeItem: (variantId: string) => void;
  updateQuantity: (variantId: string, quantity: number) => void;
  clearCart: () => void;
  openCart: () => void;
  closeCart: () => void;
}

const CartContext = createContext<CartContextValue | null>(null);

export function CartProvider({ children }: { children: React.ReactNode }) {
  const [items, setItems] = useState<CartItem[]>([]);
  const [isOpen, setIsOpen] = useState(false);

  // Hydrate from localStorage on mount
  useEffect(() => {
    const stored = localStorage.getItem("cart");
    if (stored) setItems(JSON.parse(stored));
  }, []);

  // Persist to localStorage on change
  useEffect(() => {
    localStorage.setItem("cart", JSON.stringify(items));
  }, [items]);

  function addItem(
    product: Product,
    variant: ProductVariant,
    quantity: number,
  ) {
    setItems((prev) => {
      const existing = prev.find((i) => i.variant.id === variant.id);
      if (existing) {
        return prev.map((i) =>
          i.variant.id === variant.id
            ? { ...i, quantity: i.quantity + quantity }
            : i,
        );
      }
      return [...prev, { product, variant, quantity }];
    });
  }

  function removeItem(variantId: string) {
    setItems((prev) => prev.fi