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: stringbackgroundImage: ImagePropsctaLink: DynamicLink | null
1) Add shared types first (packages/types)
Files to update
packages/types/src/routing/BannerTypename.tspackages/types/src/routing/Banner.tspackages/types/src/routing/BannerUpdate.ts(if the banner can be edited through backend patch/update flows)
What to add
- Add the new enum value in both enums in
BannerTypename.ts. - Add a typed banner model in
Banner.ts. - Add it to
AnyBannerunion and to the exports. - Add a
...UpdatePatchtype + mapping inBannerUpdate.tsif 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.tspackages/content/migrations/.../migration.sql(generated migration when new DB fields are added)packages/shared/src/util/BannerFieldRegistry.ts
What to add
- Add a new
typenameoption in thetypename: select({ options: [...] }). - Add all visible fields to
VISIBLE_BANNER_FIELDS_BY_TYPENAMEinBannerFieldRegistry.ts. - Add base metadata for every new field in
FIELD_DEFINITIONS_BY_NAME. - Mark create-time essentials with
requiredForCreate: true. - Add field-level MCP hints for non-obvious payload behavior.
- Define the actual Keystone fields (
text,relationship, etc.). - If the new banner belongs to shop functionality, also add its typename to
SHOP_BANNER_TYPENAMESinpackages/shared/src/util/projectSettings.ts. - If the new banner belongs to car-dealer functionality, also add its typename to
CAR_DEALER_BANNER_TYPENAMESinpackages/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 asstring,integer,richtext,relation_one,relation_many, ororder_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 asgridLeftBannersOrder.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
- Extend
fetchBannerForDuplicationquery with your new fields. - Copy those fields in
createBannerShell. - 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.tspackages/backend/src/modules/blocs/banners.resolver.ts
What to add
- Add a fragment for the new banner fields in
banners.graphql.ts. - Add single and batch queries (
GetPromoStripBanner,GetPromoStripBannersByIds). - Import these query constants in
banners.resolver.ts. - Add a
fetchPromoStripBannerfunction. - Add
BannerTypename.PromoStripBanneringetBatchQueryByTypename.
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
- Add
case BannerTypename.PromoStripBannertogetReferencedBanner. - Add
case BannerTypename.PromoStripBannertodecorateReferencedBanner. - Implement:
mapPromoStripBanner(...)getPromoStripBannerById(...)
- 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.tsxpackages/frontend/src/pages/data/page-translations.util.ts(if the banner has i18n keys)
What to add
- Create the new layout component.
- Add a dynamic import in
Banner.tsx. - Add the
switchcase inbannerVariant. - 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
- Add the new banner to
determineNewBannerLCPValue. - 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
- Run
yarn util:buildin repo root. - Create one CMS banner of the new type and attach it to a page.
- Duplicate that page/banner and verify new fields are copied.
- Open frontend page and confirm rendering + loading behavior.
If all 4 checks pass, the new banner integration is complete.