Documentation

Examples

Complete, production-ready examples from the sample project showing real implementations using brease-next.


Configuration File

Central configuration for section mapping and context data.

File: src/lib/brease-config.ts

import { ComponentType } from 'react';
import HeroSection from '@/sections/hero-section';
import SmallHeroSection from '@/sections/small-hero-section';
import TextContentSection from '@/sections/text-content-section';
import LatestNewsSection from '@/sections/latest-news-section';

type SectionProps = Record<string, unknown>;

// Section mapping configuration
export const sectionMap: Record<string, ComponentType<SectionProps>> = {
  hero: HeroSection,
  smallHero: SmallHeroSection,
  text: TextContentSection,
  latestNews: LatestNewsSection,
};

// Context configuration for navigations and collections
export const contextData = {
  navigations: [
    { key: 'mainNavigation', id: 'nav-a01c4cbb-21f7-46d5-a89c-564307998128' },
  ],
  collections: [
    { key: 'news', id: 'col-a01c8223-4e4a-40aa-90d9-70149e87322c' },
  ],
};

Root Layout

Root layout with metadata generation and context setup.

File: src/app/layout.tsx

import { ReactNode } from "react";
import type { Metadata } from 'next';
import { Geist, Geist_Mono } from 'next/font/google';
import FooterSection from '@/sections/footer-section';
import { BreaseContext, fetchSite } from 'brease-next';
import { contextData } from "@/lib/brease-config";

import './globals.css';

const geistSans = Geist({
  variable: '--font-geist-sans',
  subsets: ['latin'],
});

const geistMono = Geist_Mono({
  variable: '--font-geist-mono',
  subsets: ['latin'],
});

export async function generateMetadata(): Promise<Metadata> {
  const result = await fetchSite();

  if (result.success) {
    return {
      title: {
        template: `%s | ${result.data.name}`,
        default: result.data.name,
      },
      description: `${result.data.name} - ${result.data.domain}`,
    };
  }

  console.error('Failed to fetch site data for metadata:', result);

  return {
    title: {
      template: '%s | Create Next App',
      default: 'Create Next App',
    },
    description: 'Generated by create next app',
  };
}

export default function RootLayout({
  children,
}: Readonly<{
  children: ReactNode;
}>) {
  return (
    <html lang="en">
      <body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
        <BreaseContext config={contextData}>
          {children}
          <FooterSection />
        </BreaseContext>
      </body>
    </html>
  );
}

Key Features

  • Fetches site metadata from Brease
  • Wraps app with BreaseContext using contextData configuration
  • Includes global footer component
  • Sets up custom fonts

Homepage Implementation

Basic homepage that fetches and renders Brease content.

File: src/app/page.tsx

import type { Metadata } from 'next';
import { fetchPage, fetchSite, BreasePage, generateBreasePageMetadata } from 'brease-next';
import { sectionMap } from '@/lib/brease-config';
import { notFound } from 'next/navigation';

export const dynamic = 'force-static';

export async function generateMetadata(): Promise<Metadata> {
  const result = await fetchSite();
  const metadata = await generateBreasePageMetadata('/');

  if (result.success) {
    metadata.title = `${metadata.title} | ${result.data.name}`;
  }

  return metadata;
}

export default async function Home() {
  const result = await fetchPage('/');

  if (!result.success) {
    if (result.status === 404) {
      return notFound();
    }
    throw new Error(`Failed to load page: ${result.error}`);
  }

  return <BreasePage page={result.data} sectionMap={sectionMap} />;
}

Key Features

  • Static generation with dynamic = 'force-static'
  • Automatic metadata from Brease
  • Error handling for 404 and other errors
  • Dynamic section rendering

Dynamic Route with Sections

Dynamic route for subpages with static generation.

File: src/app/[subpageSlug]/page.tsx

import type { Metadata } from 'next';
import { fetchPage, generateBreasePageParams, generateBreasePageMetadata, BreasePage } from 'brease-next';
import { sectionMap } from '@/lib/brease-config';
import { notFound } from 'next/navigation';

export const dynamic = 'auto';

export async function generateStaticParams() {
  return generateBreasePageParams();
}

export async function generateMetadata({ params }: { params: Promise<{ subpageSlug: string }> }): Promise<Metadata> {
  const { subpageSlug } = await params;
  return generateBreasePageMetadata(`/${subpageSlug}`);
}

export default async function Subpage({ params }: { params: Promise<{ subpageSlug: string }> }) {
  const { subpageSlug } = await params;
  const result = await fetchPage(`/${subpageSlug}`);

  if (!result.success) {
    if (result.status === 404) {
      return notFound();
    }
    throw new Error(`Failed to load page: ${result.error}`);
  }

  return <BreasePage page={result.data} sectionMap={sectionMap} />;
}

How It Works

  1. generateStaticParams() pre-renders all pages at build time
  2. generateMetadata() creates SEO tags for each page
  3. Page component fetches and renders the content
  4. Returns 404 if page not found

Collection Entry Route

News article detail page from a collection.

File: src/app/news/[slug]/page.tsx

import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { JsonPrinter } from '@/components/json-printer';
import { fetchEntryBySlug, generateBreaseCollectionParams, type BreaseMedia } from 'brease-next';

import 'react-json-view-lite/dist/index.css';

export const dynamic = 'auto';

const NEWS_COLLECTION_ID = 'col-a01c8223-4e4a-40aa-90d9-70149e87322c';

export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {
  const { slug } = await params;
  const result = await fetchEntryBySlug(NEWS_COLLECTION_ID, `/${slug}`);

  if (!result.success) {
    console.error('Failed to fetch entry data for metadata:', result);
    return {};
  }

  const collection = result.data;
  const collectionDetails = {
    title: collection.elements.title as string || 'News article',
    slug: `/${slug}`,
    description: collection.elements.excerpt as string || 'News article excerpt.',
    image: collection.elements.blogPostMedia as BreaseMedia || null
  };

  const metadata: Metadata = {
    title: collectionDetails.title,
  };

  if (collectionDetails.description) {
    metadata.description = collectionDetails.description;
  }

  metadata.openGraph = {
    title: collectionDetails.title,
    description: collectionDetails.description,
    type: 'article',
    url: collectionDetails.slug,
    images: collectionDetails.image
      ? [{ url: collectionDetails.image.path }]
      : undefined,
  };

  return metadata;
}

export async function generateStaticParams() {
  return await generateBreaseCollectionParams(NEWS_COLLECTION_ID);
}

export default async function NewsArticle({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  const result = await fetchEntryBySlug(NEWS_COLLECTION_ID, `/${slug}`);

  if (!result.success) {
    if (result.status === 404) {
      return notFound();
    }
    throw new Error(`Failed to load collection entry: ${result.error}`);
  }

  return <JsonPrinter json={result.data.elements} />;
}

Type Casting Collection Fields

Collection entry elements are untyped. Cast them based on your schema:

const title = entry.elements.title as string;
const content = entry.elements.content as string;
const featuredImage = entry.elements.featuredImage as BreaseMedia;
const publishedAt = entry.elements.publishedAt as string;
const tags = entry.elements.tags as string[];

Section Components

Hero Section

Full-featured hero section with navigation and responsive image.

File: src/sections/hero-section.tsx

'use client';

import { BreaseImage, type BreaseMedia } from 'brease-next';
import Navigation from '@/components/navigation';

interface HeroSectionProps {
  title: string;
  body: string;
  heroMedia: BreaseMedia;
}

export default function HeroSection({ title, body, heroMedia }: HeroSectionProps) {
  return (
    <div className="bg-white dark:bg-gray-900">
      <Navigation />
      <div className="relative isolate pt-14">
        <svg
          aria-hidden="true"
          className="absolute inset-0 -z-10 size-full mask-[radial-gradient(100%_100%_at_top_right,white,transparent)] stroke-gray-200 dark:stroke-white/10"
        >
          <defs>
            <pattern
              x="50%"
              y={-1}
              id="hero-pattern"
              width={200}
              height={200}
              patternUnits="userSpaceOnUse"
            >
              <path d="M100 200V.5M.5 .5H200" fill="none" />
            </pattern>
          </defs>
          <rect fill="url(#hero-pattern)" width="100%" height="100%" strokeWidth={0} />
        </svg>
        <div className="mx-auto max-w-7xl px-6 py-24 sm:py-32 lg:flex lg:items-center lg:gap-x-10 lg:px-8 lg:py-40">
          <div className="mx-auto max-w-2xl lg:mx-0 lg:flex-auto">
            <h1 className="mt-10 text-5xl font-semibold tracking-tight text-pretty text-gray-900 sm:text-7xl dark:text-white">
              {title}
            </h1>
            <div className="mt-8 text-lg font-medium text-pretty text-gray-500 sm:text-xl/8 dark:text-gray-400">
              <div dangerouslySetInnerHTML={{ __html: body }} />
            </div>
          </div>
          <div className="mt-16 sm:mt-24 lg:mt-0 lg:shrink-0 lg:grow">
            <BreaseImage breaseImage={heroMedia} />
          </div>
        </div>
      </div>
    </div>
  );
}

Latest News Section

Section that receives collection data as props from Brease.

File: src/sections/latest-news-section.tsx

import Link from 'next/link';
import { BreaseImage, type BreaseMedia } from 'brease-next';

interface LatestNewsSectionProps {
  title: string;
  posts: {
    name: string;
    entries: {
      name: string;
      slug: string;
      elements: {
        title: string;
        body: string;
        blogPostMedia: BreaseMedia;
        excerpt: string;
      };
      uuid: string;
    }[];
  };
}

export default function LatestNewsSection({ title, posts }: LatestNewsSectionProps) {
  return (
    <div className="bg-white py-24 sm:py-32 dark:bg-gray-900">
      <div className="mx-auto max-w-7xl px-6 lg:px-8">
        <div className="mx-auto max-w-2xl text-center">
          <h2 className="text-4xl font-semibold tracking-tight text-balance text-gray-900 sm:text-5xl dark:text-white">
            {title}
          </h2>
          <p className="mt-2 text-lg/8 text-gray-600 dark:text-gray-400">
            Learn how to grow your business with our expert advice.
          </p>
        </div>
        <div className="mx-auto mt-16 grid max-w-2xl auto-rows-fr grid-cols-1 gap-8 sm:mt-20 lg:mx-0 lg:max-w-none lg:grid-cols-3">
          {posts.entries.map((post) => (
            <article
              key={post.uuid}
              className="relative isolate flex flex-col justify-end overflow-hidden rounded-2xl bg-gray-900 px-8 pt-80 pb-8 sm:pt-48 lg:pt-80 dark:bg-gray-800"
            >
              <BreaseImage
                breaseImage={post.elements.blogPostMedia}
                className="absolute inset-0 -z-10 size-full object-cover"
              />
              <div className="absolute inset-0 -z-10 bg-linear-to-t from-gray-900 via-gray-900/40 dark:from-black/80 dark:via-black/40" />
              <div className="absolute inset-0 -z-10 rounded-2xl inset-ring inset-ring-gray-900/10 dark:inset-ring-white/10" />

              <h3 className="mt-3 text-lg/6 font-semibold text-white">
                <Link href={`/news${post.slug}`}>
                  <span className="absolute inset-0" />
                  {post.elements.title}
                </Link>
              </h3>
              <p className="line-clamp-1">{post.elements.excerpt}</p>
            </article>
          ))}
        </div>
      </div>
    </div>
  );
}

Responsive navigation using useBrease hook with the new destructuring pattern.

File: src/components/navigation.tsx

'use client';

import Link from 'next/link';
import Image from 'next/image';
import { useState } from 'react';
import { useBrease } from 'brease-next';
import { Dialog, DialogPanel } from '@headlessui/react';
import { Bars3Icon, XMarkIcon } from '@heroicons/react/24/outline';

export default function Navigation() {
  const [mobileMenuOpen, setMobileMenuOpen] = useState(false);

  const { navigations } = useBrease();
  const { mainNavigation } = navigations;

  return (
    <header className="absolute inset-x-0 top-0 z-50">
      <nav aria-label="Global" className="flex items-center justify-between p-6 lg:px-8">
        <div className="flex lg:flex-1">
          <Link href="/" className="-m-1.5 p-1.5">
            <span className="sr-only">Your Company</span>
            <Image
              alt=""
              src="https://tailwindcss.com/plus-assets/img/logos/mark.svg?color=indigo&shade=600"
              width={1040}
              height={626}
              className="h-8 w-auto dark:hidden"
            />
            <Image
              alt=""
              src="https://tailwindcss.com/plus-assets/img/logos/mark.svg?color=indigo&shade=500"
              width={1040}
              height={626}
              className="h-8 w-auto not-dark:hidden"
            />
          </Link>
        </div>
        <div className="flex lg:hidden">
          <button
            type="button"
            onClick={() => setMobileMenuOpen(true)}
            className="-m-2.5 inline-flex items-center justify-center rounded-md p-2.5 text-gray-700 dark:text-gray-200"
          >
            <span className="sr-only">Open main menu</span>
            <Bars3Icon aria-hidden="true" className="size-6" />
          </button>
        </div>
        <div className="hidden lg:flex lg:gap-x-12">
          {mainNavigation?.items.map((item) => (
            <Link
              key={item.value}
              href={item?.target?.slug || item?.url || '#'}
              className="text-sm/6 font-semibold text-gray-900 dark:text-white"
            >
              {item.value}
            </Link>
          ))}
        </div>
      </nav>
      <Dialog open={mobileMenuOpen} onClose={setMobileMenuOpen} className="lg:hidden">
        <div className="fixed inset-0 z-50" />
        <DialogPanel className="fixed inset-y-0 right-0 z-50 w-full overflow-y-auto bg-white p-6 sm:max-w-sm sm:ring-1 sm:ring-gray-900/10 dark:bg-gray-900 dark:sm:ring-gray-100/10">
          <div className="flex items-center justify-between">
            <Link href="/" className="-m-1.5 p-1.5">
              <span className="sr-only">Your Company</span>
              <Image
                alt=""
                src="https://tailwindcss.com/plus-assets/img/logos/mark.svg?color=indigo&shade=600"
                width={1040}
                height={626}
                className="h-8 w-auto dark:hidden"
              />
              <Image
                alt=""
                src="https://tailwindcss.com/plus-assets/img/logos/mark.svg?color=indigo&shade=500"
                width={1040}
                height={626}
                className="h-8 w-auto not-dark:hidden"
              />
            </Link>
            <button
              type="button"
              onClick={() => setMobileMenuOpen(false)}
              className="-m-2.5 rounded-md p-2.5 text-gray-700 dark:text-gray-200"
            >
              <span className="sr-only">Close menu</span>
              <XMarkIcon aria-hidden="true" className="size-6" />
            </button>
          </div>
          <div className="mt-6 flow-root">
            <div className="-my-6 divide-y divide-gray-500/10 dark:divide-white/10">
              <div className="space-y-2 py-6">
                {mainNavigation?.items.map((item) => (
                  <Link
                    key={item.value}
                    href={item?.target?.slug || item?.url || '#'}
                    className="-mx-3 block rounded-lg px-3 py-2 text-base/7 font-semibold text-gray-900 hover:bg-gray-50 dark:text-white dark:hover:bg-white/5"
                  >
                    {item.value}
                  </Link>
                ))}
              </div>
            </div>
          </div>
        </DialogPanel>
      </Dialog>
    </header>
  );
}

Key Features

  • Uses new useBrease() destructuring pattern
  • Accesses mainNavigation from navigations object
  • Responsive mobile menu with Headless UI
  • Handles both internal and external links
  • Dark mode support

Next.js Configuration

Complete configuration with redirects, images, and CORS.

File: next.config.ts

import type { NextConfig } from "next";
import { fetchRedirects, type BreaseRedirect } from "brease-next";

const nextConfig: NextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 's3.eu-central-1.amazonaws.com',
        pathname: '/**',
      }
    ]
  },
  async redirects() {
    const result = await fetchRedirects();

    if (!result.success) {
      console.error('Failed to fetch redirects:', result.error);
      return [];
    }

    return result.data.map((redirect: BreaseRedirect) => {
      return {
        source: redirect.source,
        destination: redirect.destination,
        permanent: (redirect.permanent === '301' || redirect.permanent === '308'),
      }
    })
  },
  async headers() {
    return [
      {
        source: "/:path*",
        headers: [
          {
            key: "Access-Control-Allow-Origin",
            value: 'https://app.brease.io, https://dev.brease.io, https://*.vercel.app'
          },
        ],
      },
    ];
  },
};

export default nextConfig;

Configuration Features

  • Remote Images: S3 bucket pattern for Brease media
  • Dynamic Redirects: Fetched from Brease at build time
  • CORS Headers: Enables Brease editor preview functionality
  • All imports from brease-next package

Next Steps

For troubleshooting and common issues, see Troubleshooting.

Previous
Implementation Guide