Next.js Contentful StarterNext.js Contentful Starter

Components

Components architecture guide

There are a few interesting opinions that go into the component design of this project, let's break it down starting from getting an overview of project structure.

Structure

You will notice the following structure in the this project.

hero-banner-ctf.tsx
hero-banner-ctf-client.tsx
index.tsx
hero-banner.tsx
hero-banner.stories.tsx
index.ts

Let's break down each layer and component type and define it's role and purpose.

Component Layers

We'll start from UI components and work our way up.

UI components

Example: hero-banner.tsx

UI components are simply defined as purely UI React components with ability to manage state or render things in slots, but for the purposes of this project they are designed to be share-able across an ecosystem of React projects that are not necessarily built with Next.js or Contentful. More ideas about this concept are described in UI folder docs.

Contentful integration components

Server Component

Example: hero-banner-ctf.tsx

These components are designed to integrate Contentful's content model to the UI components's props. This includes things like re-mapping fields to UI prop values, mapping slots, injecting Next.js primitives like links or images.

This component is also responsible for data-fetching any additional data that is not available as props at the time of rendering the component.

These components could be React client components, but then they will lose ability to do data-fetching or read dynamic APIs like cookies() and headers(), hence, we have them as React Server Components by convention. This means that if we want to store state or do other interactive things on the client, we will lose this ability. This is where the client counterpart of the server component kicks in

Client Component

Example: hero-banner-ctf-client.tsx

These components serve 2 purposes:

  1. For components that need interactivity, like tabs or forms, they will serve as the transitioning point from React Server Components to traditional pre-rendered SSR components with use client annotation. They will receive props from server components and have no further access to data-fetching other than Server Actions passed as props.
  2. For components that don't need interactivity, this layer is optional, but there is a feature that you will lose if you don't have it, and that is: instant live preview updates. As you might already know, Contentful's Live Preview feature will let you edit content in-place while previewing it side-by-side, but for this feature to be fast and responsive, Contentful will use React state to pass down modified content values via iframe sendMessage in Live Preview mode. This will result in instant refresh of preview mode, and it would be a bummer to lose this feature. For this reason, every component that needs to use live preview instant updates should be a client component and use React state.

You may choose to not use client components and forego live preview updates. In future we may add a client component layerconditionally based on draft mode being enabled.

Adding new components

Let's explore how we can create new components and integrate them end to end.

1. Create UI component

Since the design system is based on shadcn/UI, the simplest way to start building a new component is by either installing it from shadcn/ui library via shadcn CLI, or by generating one with AI on v0.dev and installing using the same shadcn/ui CLI.

The end result should be a component added to packages/ui/components. Let's say it's accordion.tsx which exports Accordion and AccordionItem. You can start by testing the component in Storybook, working on some variants, updating styles and polishing the UI. After the UI component is ready, you can proceed to create a content model in Contentful.

2. Create a content model in Contentful.

Now that you have your UI component, you'll want to give it some data, for that you need to create a data model in Contentful that can serve your needs. You can create a content type that serves your desired content and configurability, for example for Accordion you may need 2 content types.

Accordion

Field LabelTypeDescription
Itemsreferencereference to accordionItem
Open by defaultbooleanwhether to open accordion by default

Accordion Item

Field LabelTypeDescription
Labelshort textItem label
ContentrichtextItem contents

You'll need to allow your component (Accordion) to be referenced in other reference fields, for example Top Components field in Landing Page in this content model.

Lastly, you'll need to make sure your code knows about the new content model changes by pointing at the environment where you made changes using CONTENTFUL_ENVIRONMENT= variable, but also by exporting the GraphQL schema using:

pnpm generate:schema

Exporting a schema after content model updates is needed to infer types in data-fetching. More details about gql.tada usage can be found here.

3. Create integrated component

Before we proceed with data-fetching, we should create a new home for our integrated component. This will be a new folder in components named after content type name and with -ctf prefix indicating this is a Contentful integration component. For example components/accordion-ctf is a good name, but you can adopt other prefixes, like components/topic-[topic-name].

For now I want you to think in simple terms what we're going to expect in our component, so let's define a placeholder:

accordion-ctf.tsx
export function AccordionCtf(props: any) {
  return {JSON.stringify(props, null, 2)};
};

Now that we have a home, let's talk data fetching.

We use top-level data-fetching on this project (for reasons described here), this means that you only need to define the component's required data as a GraphQL fragment and include that fragment in existing data-fetching. You can do so by listing fields you're interested to render like so:

accordion-ctf.tsx
export const ComponentAccordionFieldsFragment = graphql(/* GraphQL */ `
  fragment ComponentAccordionFields on Accordion {
    __typename
    sys {
      id
    }
    heading
    expandType
    itemsCollection {
      items {
        sys {
          id
        }
        heading
        body {
          json
        }
      }
    }
  }
`);

You can define this fragment in any file, but we recommend collocating it with your component in the component folder. This will be enforced by fragment-masking unless you opt out for some reason (not recommended).

Proceed to import and include your fragment (with ... operator) in /apps/marketing/app/[[...slug]]/page.tsx as shown below:

topSectionCollection(limit: 10) {
  items {
    ...ComponentHeroBannerFields
    ...ComponentDuplexFields
    ...ComponentAccordionFields 
  }
}

As soon as you have the GraphQL fragment expanded on the top-level page query, you will get the exact shape of the GraphQL response as props in your component, but only after you implement a ComponentRenderer mapping. We'll implement mapping and come back to finishing this component.

4. Register your component

We will register your component in /apps/marketing/components/component-renderer/mappings.ts

All you need to do here is return a dynamic import of your component mapped to the Contentful content type GraphQL type (__typename). The typename will be derived from your content type ID, for example ComponentAccordion is going to be the name for componentAccordion content type id.

Update mappings.ts like this:

components/component-renderer/mappings.ts
import { AccordionCtf } from '#/components/accordion-ctf/accordion-ctf';
export const componentMap = {
  // rest of the components
  ComponentAccordion: AccordionCtf, 
};

As soon as you do that, accordion-ctf.tsx will start receiving props from page-level GraphQL query that fetches the component fields you specified in the fragment, so let's test that by opening our Contentful and adding a new Accordion component on a test page.

You should see the JSON object returned by GraphQL query when rendering the component.

5. Add Typescript inference with gql.tada

When defined our GraphQL fragment and passed it to graphql function from gql.tada, we have created a TadaDocumentNode type matching the shape of our fragment, but how do we use this type to help with Typescript autocomplete?

Well, all the steps are documented in gql.tada docs, but let's break it down for our case

First we need to create a Props type that lists our component props. By convention we use data prop to define data passed from GraphQL and this will leave some room for other props that control behavior. It is important to keep them separate as data will be inferred by gql.tada and we will have no control over those types.

To do that, we need to use FragmentOf and pass it the type of our fragment using typeof.

accordion-ctf.tsx
export type AccordionProps = {
  data: FragmentOf<typeof ComponentAccordionFieldsFragment>;
};

What gql.tada will do, it will check out fragment and return a type matching our query.

Now you can use the Props type in your component:

accordion-ctf.tsx
export const AccordionCtf = (props: AccordionProps) => {
  return {JSON.stringify(props, null, 2)};
};

You can test type inference using autocomplete in your editor on props.data. query. You should see all top level fields, however, there is a catch.

Because gql.tada fragment masking promotes collocation of fragments, in order to read the actual data you will need to unmask it first. To unmask the data, you will need the ComponentAccordionFieldsFragment and props.data and pass them to readFragment.

accordion-ctf.tsx
const data = readFragment(ComponentAccordionFieldsFragment, props.data);

Now you can use data.label and get Typescript to be happy.

Now that we have the data, we could render the UI component here, but we'll take a longer path and show how to use this pattern with the layered client component (to use state and have live preview as described above).

6. Add Client component

We'll start of with a boilerplate in /apps/marketing/components/accordion-ctf/accordion-ctf-client.tsx. Create the component with the following code:

accordion-ctf-client.tsx
import { ResultOf } from 'gql.tada';
 
export const AccordionCtfClient = (props: {
  data: ResultOf<typeof ComponentAccordionFieldsFragment>
}) => {
  return {JSON.stringify(props, null, 2)};
}

And let's use the new client component in AccordionCtf like so:

accordion-ctf.tsx
import { AccordionCtfClient } from './accordion-ctf-client';
export const AccordionCtf = (props: AccordionProps) => {
  const data = readFragment(ComponentAccordionFieldsFragment, props.data);
 
  return <AccordionCtfClient data={data} />;
}

Now, you should get the same result as on last step, but this time around you have a client-component doing the rendering of content, it means you can intercept and modify content with state for Live Preview!

Let's unpack the gql.tada helper ResultOf first though. When we unmask a fragment using readFragment or @_unmask directive in the query, you will no longer get a masked version, it means our Typescript approach for components that receive unmasked data will change from using FragmentOf, to using ResultOf, indicating that this type is a result of unmasking.

Now that we have our data printed, let's render UI components!

  1. Render UI component

Remember how we layer our components, React Server Component -> Client Component -> UI Component. The Client Component here will be responsible for taking the data from props.data and mapping it to props on UI component.

We can do simple one-to-one mappings, we can wrap or render our props, we can pass components as slots, everything is possible, so we'll look at the larger example here to get a sense of this activity:

export const AccordionCtfClient = (props: {
  data: ResultOf<typeof ComponentAccordionFieldsFragment>
}) => {
  const { data } = props;
 
  const accordionItems = data.itemsCollection?.items.map((item) => {
    return {
      id: item?.sys.id,
      heading: item?.heading,
      body: item?.body && <RichTextCtf {...item.body} />
    }
  });
 
  return (
    <Accordion
      heading={data.heading}
      items={accordionItems}
      expandType={data.expandType === "multiple" ? "multiple" : "single"}
    />
  );
};

After we do this change, we should start seeing real UI rendering on the page, powered by real Contentful data, but we're not done yet.

  1. Wrap data with live preview state

Remember why we bother to create a Client Component in the first place? To get React state support for Live Preview (and interactivity). Let's see how easy it is to configure Contentful Live Preview using useComponentPreview hook. You'll need to pass your original data to useComponentPreview and you'll get modified data from React state in return, you'll also get a helper that lets you annotate components HTML with "Inspector Mode" elements, we call this helper addAttributes. Here is the full example.

export const AccordionCtfClient = (props: {
  data: ResultOf<typeof ComponentAccordionFieldsFragment>
}) => {
  const { data: originalData } = props;

  // Pass original fetched data, get modified inspector mode state data and addAttributes inspector mode helper back.
  const { data, addAttributes } = useComponentPreview(originalData); 
 
  const accordionItems = data.itemsCollection?.items.map((item) => {
    return {
      id: item?.sys.id,
      heading: item?.heading,

      // Attach attributes for Contentful Inspector Mode to any local components.
      body: item?.body && <div {...addAttributes("bodyText")}><RichTextCtf {...item.body} /></div> 
    }
  });
 
  return (
    <Accordion

      // Allow UI component to attach attributes for Contentful Inspector Mode.
      addAttributes={addAttributes} 
      heading={data.heading}
      items={accordionItems}
      expandType={data.expandType === "multiple" ? "multiple" : "single"}
    />
  );
};

The /components/hooks/use-component-preview.ts helper hook effectively combines 2 Contentful hooks, useContentfulLiveUpdates and useContentfulInspectorMode and lets you edit content in Contentful and get instant updates, as well as annotate content for inspector mode.

The addAttributes helper is a quite clever hack, we add support to all UI components to receive this helper and we use inversion of control to let the UI component dictate the field names it renders, but ask for attributes from wrappers like our integration components.

For example in our UI componentaccordion.tsx we can call our annotated field 'label' but if the fieldname is different in Contentful or we want to use this annotation outside of Contentful and apply different rules, we can pass an addAttributes prop that can give control to the integrator (Contentful, or other wrappers) what attributes to pass.

accordion.tsx
<Accordion type="single">
  <AccordionItem value="item-1">
    <AccordionTrigger {...addAttributes('label')}>Is it accessible?</AccordionTrigger>
    <AccordionContent {...addAttributes('content')}>Yes. It adheres to the WAI-ARIA design pattern.</AccordionContent>
  </AccordionItem>
</Accordion>

You should now have a fully functional component available in Contentful with an integrated inspector mode and live preview instant updates abilities.

What's next?

  • You might no longer need to manually annotate components for inspector mode, see our built-in support for Content Link
  • Did you know we have a CLI that let's you generate stubs for those component files? Learn more about our CLI