Banners
Add a Banner

Add a New Banner

This guide shows the complete process for adding a new banner type, in the required order.

Example banner in this guide: PromoStripBanner

  • typename: BannerTypename.PromoStripBanner
  • fields:
    • message: string
    • backgroundImage: ImageProps
    • ctaLink: DynamicLink | null

1) Add shared types first (packages/types)

Files to update

  • packages/types/src/routing/BannerTypename.ts
  • packages/types/src/routing/Banner.ts
  • packages/types/src/routing/BannerUpdate.ts (if the banner can be edited through backend patch/update flows)

What to add

  1. Add the new enum value in both enums in BannerTypename.ts.
  2. Add a typed banner model in Banner.ts.
  3. Add it to AnyBanner union and to the exports.
  4. Add a ...UpdatePatch type + mapping in BannerUpdate.ts if needed.

Why

@haendlerapp/types is the contract shared by CMS, backend, and frontend. If this is missing first, all downstream layers drift.

Example

// BannerTypename.ts
enum BannerTypename {
  // ...
  PromoStripBanner = 'PromoStripBanner',
}
 
enum BannerTypenameType {
  // ...
  PromoStripBanner = 'PromoStripBanner',
}
// Banner.ts
type PromoStripBanner =
  DefaultBannerProps<BannerTypename.PromoStripBanner> &
    BannerCSS & {
      id: string;
      message: string;
      backgroundImage: ImageProps;
      ctaLink: DynamicLink | null;
    };
 
type AnyBanner =
  // ...
  | PromoStripBanner;
// BannerUpdate.ts (only if update-patch support is needed)
type PromoStripBannerUpdatePatch = BannerSharedUpdatePatch & {
  message?: string | null;
};
 
type BannerUpdatePatchByTypename = {
  // ...
  [BannerTypename.PromoStripBanner]: PromoStripBannerUpdatePatch;
};

2) Add CMS fields (packages/content/schema/banner.ts)

Files to update

  • packages/content/schema/banner.ts
  • packages/content/migrations/.../migration.sql (generated migration when new DB fields are added)
  • packages/shared/src/util/BannerFieldRegistry.ts

What to add

  1. Add a new typename option in the typename: select({ options: [...] }).
  2. Add all visible fields to VISIBLE_BANNER_FIELDS_BY_TYPENAME in BannerFieldRegistry.ts.
  3. Add base metadata for every new field in FIELD_DEFINITIONS_BY_NAME.
  4. Mark create-time essentials with requiredForCreate: true.
  5. Add field-level MCP hints for non-obvious payload behavior.
  6. Define the actual Keystone fields (text, relationship, etc.).
  7. If the new banner belongs to shop functionality, also add its typename to SHOP_BANNER_TYPENAMES in packages/shared/src/util/projectSettings.ts.
  8. If the new banner belongs to car-dealer functionality, also add its typename to CAR_DEALER_BANNER_TYPENAMES in packages/shared/src/util/projectSettings.ts.

Why

This makes the new banner selectable in CMS, shows the correct fields in Admin UI, enforces required values, and exposes enough MCP metadata for AI tools to create/update the banner correctly.

For shop-related banners, this is also the point where feature gating must be wired. If the typename is not added to SHOP_BANNER_TYPENAMES, the CMS will not hide it when shop is disabled and the backend will not filter it consistently.

The same applies to car-related banners. If the typename is not added to CAR_DEALER_BANNER_TYPENAMES, the CMS will not hide it when the car-dealer feature is disabled and the backend will not filter it consistently.

CustomerAccountLoginBanner and CustomerAccountBanner are still treated as shop banners for now because the current account implementation is Shopify-based. Keep that in mind when adding account-related banner types: they may move into a provider-agnostic grouping once other account systems are supported.

Example

// packages/shared/src/util/BannerFieldRegistry.ts
const FIELD_DEFINITIONS_BY_NAME: Record<string, BannerFieldBaseDefinition> = {
  // ...
  promoMessage: {
    type: 'string',
    hints: ['A promo strip without a message is filtered as invalid on fetch.'],
  },
  promoBackgroundImage: {
    type: 'relation_one',
    relationshipWrapper: 'oneToOne',
    hints: ['Use a connected ImageList record for the background image.'],
  },
  promoCtaLink: {
    type: 'relation_one',
    relationshipWrapper: 'oneToOne',
  },
};
 
export const VISIBLE_BANNER_FIELDS_BY_TYPENAME = {
  // ...
  [BannerTypename.PromoStripBanner]: withDefaultVisibleFields([
    defineBannerField('promoMessage', { requiredForCreate: true }),
    defineBannerField('promoBackgroundImage', { requiredForCreate: true }),
    defineBannerField('promoCtaLink'),
  ]),
};
// typename select option
{
  label: 'Promo Strip Banner',
  value: BannerTypename.PromoStripBanner,
}
// fields
promoMessage: text({
  label: 'Nachricht',
  ui: fieldModeFor('promoMessage'),
}),
 
promoBackgroundImage: relationship({
  ref: 'ImageList',
  many: false,
  label: 'Hintergrundbild',
  ui: fieldModeFor('promoBackgroundImage'),
}),
 
promoCtaLink: relationship({
  ref: 'Link',
  many: false,
  label: 'CTA Link',
  ui: fieldModeFor('promoCtaLink'),
}),

MCP field metadata

Every visible banner field now carries the metadata used by the Admin UI and the MCP info endpoint.

  • name: schema field name accepted by create/update payloads.
  • type: simplified payload type such as string, integer, richtext, relation_one, relation_many, or order_entries.
  • requiredForCreate: fields the CMS/admin flow should treat as required when creating this banner.
  • autoManaged: order fields usually written by the Admin UI, such as gridLeftBannersOrder.
  • enumValues: exact allowed values for enum-like fields.
  • relationshipWrapper: tells MCP callers whether to use a one-to-one or one-to-many relation wrapper.
  • hints: behavior notes for fields that save successfully but may render invalid when incomplete.

Use field hints for backend hydration rules that are not hard Keystone validation. For example, if mapPromoStripBanner returns null when promoMessage or promoBackgroundImage is missing, document that on the corresponding fields.

For a full explanation of this structure, see MCP Banner Fields.

3) Update duplication logic (packages/content/schema/duplicate.ts)

File to update

  • packages/content/schema/duplicate.ts

What to add

  1. Extend fetchBannerForDuplication query with your new fields.
  2. Copy those fields in createBannerShell.
  3. If your banner owns nested entities, add deep-copy logic in patchBannerWithOwnedChildrenAndNestedContent.

Why

Without this, duplicating a page/banner can silently drop new banner data.

Example

// fetchBannerForDuplication query
promoMessage
promoBackgroundImage { id }
promoCtaLink { id }
 
// createBannerShell data
promoMessage: sourceBanner?.promoMessage ?? null,
promoBackgroundImage: sourceBanner?.promoBackgroundImage?.id
  ? { connect: { id: String(sourceBanner.promoBackgroundImage.id) } }
  : undefined,
promoCtaLink: sourceBanner?.promoCtaLink?.id
  ? { connect: { id: String(sourceBanner.promoCtaLink.id) } }
  : undefined,

4) Add backend query + resolver support

Files to update

  • packages/backend/src/globals/graphql/queries/banners.graphql.ts
  • packages/backend/src/modules/blocs/banners.resolver.ts

What to add

  1. Add a fragment for the new banner fields in banners.graphql.ts.
  2. Add single and batch queries (GetPromoStripBanner, GetPromoStripBannersByIds).
  3. Import these query constants in banners.resolver.ts.
  4. Add a fetchPromoStripBanner function.
  5. Add BannerTypename.PromoStripBanner in getBatchQueryByTypename.

Why

The service layer needs both single and batched query access for normal rendering and optimized page fetches.

Example

// packages/backend/src/globals/graphql/queries/banners.graphql.ts
const PROMO_STRIP_FIELDS = gql`
  fragment PromoStripBannerFields on Banner {
    promoMessage
    promoBackgroundImage {
       ...ImageDetails
    }
    promoCtaLink {
      ...LinkLiteDetails
    }
  }
  ${IMAGE_FRAGMENT}
  ${LINK_LITE_FRAGMENT}
`;
 
const GET_PROMO_STRIP_BANNER_QUERY = gql`
  query GetPromoStripBanner($id: ID!) {
    banner(where: { id: $id, typename: { equals: PromoStripBanner } }) {
      ...PromoStripBannerFields
    }
  }
  ${PROMO_STRIP_FIELDS}
`;
 
const GET_PROMO_STRIP_BANNERS_BY_IDS_QUERY = gql`
  query GetPromoStripBannersByIds($ids: [ID!]!) {
    banners(
      where: { id: { in: $ids }, typename: { equals: PromoStripBanner } }
    ) {
      ...PromoStripBannerFields
    }
  }
  ${PROMO_STRIP_FIELDS}
`;
// packages/backend/src/modules/blocs/banners.resolver.ts
import {
  GET_PROMO_STRIP_BANNER_QUERY,
  GET_PROMO_STRIP_BANNERS_BY_IDS_QUERY,
} from '@/globals/graphql/queries/banners.graphql';
 
// inside getBatchQueryByTypename(...)
case BannerTypename.PromoStripBanner:
  return GET_PROMO_STRIP_BANNERS_BY_IDS_QUERY;
 
public fetchPromoStripBanner(id: string, fetchPolicy: FetchPolicy) {
  return this.fetchBannerById<GraphQL.GetPromoStripBannerQuery>({
    label: 'fetchPromoStripBanner',
    errorMessage: 'Failed to query promo strip banner',
    query: GET_PROMO_STRIP_BANNER_QUERY,
    id,
    fetchPolicy,
  });
}

5) Add backend service functions

File to update

  • packages/backend/src/modules/blocs/banners.service.ts

What to add

  1. Add case BannerTypename.PromoStripBanner to getReferencedBanner.
  2. Add case BannerTypename.PromoStripBanner to decorateReferencedBanner.
  3. Implement:
    • mapPromoStripBanner(...)
    • getPromoStripBannerById(...)
  4. If this banner should support patch updates, add the type case in buildBannerUpdateInput.

Why

The service is the backend normalization layer that transforms raw CMS GraphQL responses into runtime-safe frontend banner objects.

Example

// packages/backend/src/modules/blocs/banners.service.ts
private mapPromoStripBanner(
  data: ApolloLink.Result<GraphQL.GetPromoStripBannerQuery>['data'],
  originDomain?: string | null
): PromoStripBanner | null {
  const banner = data?.banner;
  if (!banner?.id || !banner.promoMessage || !banner.promoBackgroundImage) {
    return null;
  }
 
  return {
    typename: BannerTypename.PromoStripBanner,
    id: banner.id,
    title: banner.title ?? null,
    titleTag: banner.titleTag ?? 'h2',
    titleAlign: banner.titleAlign ?? 'left',
    cssId: banner.cssId ?? null,
    cssClasses: banner.cssClasses ?? null,
    message: banner.promoMessage,
    backgroundImage: DbParser.getImage(banner.promoBackgroundImage),
    ctaLink: this.resolveLink(banner.promoCtaLink, originDomain),
  };
}
 
private async getPromoStripBannerById(
  id: string,
  fetchPolicy: FetchPolicy,
  originDomain?: string | null
): Promise<PromoStripBanner | null> {
  return this.bannersResolver
    .fetchPromoStripBanner(id, fetchPolicy)
    .then((query) => this.mapPromoStripBanner(query.data, originDomain))
    .catch((err) => {
      logger.error(
        `[getPromoStripBannerById] Fetching promo strip banner with id: ${id} failed.`,
        err
      );
      return null;
    });
}
// getReferencedBanner(...)
case BannerTypename.PromoStripBanner:
  result = await this.getPromoStripBannerById(info.id, fetchPolicy, originDomain);
  break;
 
// decorateReferencedBanner(...)
case BannerTypename.PromoStripBanner:
  result = this.mapPromoStripBanner(data, originDomain);
  break;
// buildBannerUpdateInput(...)
case BannerTypename.PromoStripBanner: {
  const typedPatch =
    patch as BannerUpdatePatch<BannerTypename.PromoStripBanner>;
  return {
    ...common,
    ...(typedPatch.message !== undefined
      ? { promoMessage: typedPatch.message }
      : {}),
  };
}

6) Update frontend renderer

Files to update

  • packages/frontend/src/components/blocks/layout/PromoStripBanner.tsx (new)
  • packages/frontend/src/components/ui/banners/Banner.tsx
  • packages/frontend/src/pages/data/page-translations.util.ts (if the banner has i18n keys)

What to add

  1. Create the new layout component.
  2. Add a dynamic import in Banner.tsx.
  3. Add the switch case in bannerVariant.
  4. Add translation namespace if required.

Why

The page renderer chooses the visual component by bannerData.typename; if not registered there, the banner will never render.

Example

// packages/frontend/src/components/blocks/layout/PromoStripBanner.tsx
import type { PromoStripBanner as PromoStripBannerType } from '@haendlerapp/types';
 
type PromoStripBannerProps = {
  component: Omit<PromoStripBannerType, 'typename'>;
  priority: boolean;
};
 
function PromoStripBanner({ component, priority }: PromoStripBannerProps) {
  return (
    <section className="promo-strip-banner relative w-full">
      <img
        src={component.backgroundImage.file.url}
        alt={component.backgroundImage.alt ?? ''}
        loading={priority ? 'eager' : 'lazy'}
      />
      <p>{component.message}</p>
    </section>
  );
}
 
export { PromoStripBanner };
// packages/frontend/src/components/ui/banners/Banner.tsx
const PromoStripBanner = dynamic(() =>
  import('@/components/blocks/layout/PromoStripBanner').then(
    (c) => c.PromoStripBanner
  )
);
 
// inside switch(bannerData.typename)
case BannerTypename.PromoStripBanner:
  return (
    <BannerWrapper {...wrapperProps}>
      <PromoStripBanner component={bannerData} priority={priority} />
    </BannerWrapper>
  );
// packages/frontend/src/pages/data/page-translations.util.ts
const STANDARD_PAGE_TRANSLATION_NAMESPACES = [
  // ...
  'components/blocks/layout/PromoStripBanner',
] as const;

7) Update LCP evaluator

File to update

  • packages/frontend/src/hooks/useLcpEvaluator.ts

What to add

  1. Add the new banner to determineNewBannerLCPValue.
  2. If it can contain large media, also include it in checkGridWrapperForMedia.

Why

The evaluator controls eager vs lazy loading strategy. Missing cases can cause either unnecessary eager loads or late LCP content.

Example

// packages/frontend/src/hooks/useLcpEvaluator.ts
function checkGridWrapperForMedia(banner: GridWrapperBanner): boolean {
  // ...
  switch (column.typename) {
    case BannerTypename.PromoStripBanner:
      return true;
    // existing cases...
  }
}
 
function determineNewBannerLCPValue(banner: AnyBanner): number {
  switch (banner.typename) {
    case BannerTypename.PromoStripBanner:
      return 5; // image-heavy banner; keep eager threshold behavior similar to ImageBlock
    // existing cases...
  }
}

8) Validate end-to-end

  1. Run yarn util:build in repo root.
  2. Create one CMS banner of the new type and attach it to a page.
  3. Duplicate that page/banner and verify new fields are copied.
  4. Open frontend page and confirm rendering + loading behavior.

If all 4 checks pass, the new banner integration is complete.