Custom blocks
Build, register, and configure your own block.
A block is a config-driven React component. You write the component once; what it shows, how it looks, and how it behaves all come from the config the app fetches at launch. The promo banner below goes from config interface to on-screen placement.
Check the catalog first
The platform ships hundreds of ready-made, fully config-driven blocks — image banners, product grids, accordion lists, and more — registered by registerCommonBlocks. If one of them matches your design, you only need its id in the screen config. Write a new block when nothing existing fits.
How a block's config is shaped
Every block reads three config sections:
| Section | Purpose |
|---|---|
source | Data — what the block displays (text, URLs, ids, links) |
style | Visuals — colors, spacing, typography, layout |
options | Behavior — toggles, flags, thresholds |
Keep this split strict: all data through source, all visuals through style, all behavior through options. It is what lets merchants change the block from the Appbrew dashboard without touching your code.
Define the config interface
Start with the contract, not the component. Type the exact shape your block expects. That type doubles as the schema you document for merchants at the end.
export interface PromoBannerConfig {
source?: {
title?: string
subtitle?: string
imageUrl?: string
link?: { kind: string; value: string }
}
style?: {
root?: object
title?: object
subtitle?: object
image?: { aspectRatio?: number }
}
options?: {
showSubtitle?: boolean
}
}Everything is optional. Config comes from a dashboard. Your block has to survive any subset of it.
Write the component
Every block accepts BaseBlockProps: the screenId, componentId, and instanceId that locate its config entry. Resolve the entry with useBlock, then its merged settings with useBlockSettings, and null-check before rendering. Compose the UI from @gauntlet/ui-builder elements (see the catalog) rather than raw React Native primitives.
import React from 'react'
import { useWindowDimensions } from 'react-native'
import { BaseBlockProps } from '@gauntlet/types'
import { useBlock, useBlockSettings } from '@gauntlet/state'
import { Column, Image, Link, Text } from '@gauntlet/ui-builder'
export function PromoBanner({
screenId,
componentId,
instanceId,
}: BaseBlockProps) {
const { width } = useWindowDimensions()
const block = useBlock(screenId, componentId, instanceId)
const settings = useBlockSettings(block) as PromoBannerConfig | undefined
if (!block || !settings?.source?.title) {
console.warn(`[${componentId}] missing required config`)
return null
}
const { title, subtitle, imageUrl, link } = settings.source
const showSubtitle = settings.options?.showSubtitle ?? true
const banner = (
<Column style={settings.style?.root}>
{imageUrl ? (
<Image
uri={imageUrl}
width={width}
aspectRatio={settings.style?.image?.aspectRatio ?? 2}
resizeMode="cover"
/>
) : null}
<Text style={settings.style?.title}>{title}</Text>
{showSubtitle && subtitle ? (
<Text style={settings.style?.subtitle}>{subtitle}</Text>
) : null}
</Column>
)
if (!link) return banner
return (
<Link style={{ root: {} }} link={link}>
{banner}
</Link>
)
}Three habits, every block:
- Fail soft. Missing required config returns
nullwith a warning, never a crash. A merchant mid-edit in the dashboard will send your block partial config. - Default with
??. Every optional value gets a sensible fallback. - Optional-chain everything that comes from config.
Register the block
The app builds its block registry in src/app/register-blocks.ts. Your block goes in under a unique id, and that id is the componentId the config will reference. Two forms do the same job: the app extension host from @gauntlet/brewery (platform 0.27.0 and later), and blockRegistry, which works on every version.
import { app } from '@gauntlet/brewery'
import { PromoBanner } from './blocks/promo-banner'
app.components.register('block', 'promo-banner', PromoBanner)Returns an undo, and the same call registers screens, icons, and product cards — see Extending app behavior.
Place it on a screen
A block renders when a screen's config includes an entry with its componentId. Add one to a screen in a draft theme so the live app is untouched:
{
"componentId": "promo-banner",
"instanceId": "promo-banner-1",
"source": {
"title": "Summer Sale",
"subtitle": "Up to 50% off selected items",
"imageUrl": "https://cdn.example.com/banner.jpg",
"link": { "kind": "screen", "value": "collection-summer" }
},
"style": {
"root": { "backgroundColor": "#fff8e1", "borderRadius": 12, "margin": 16 },
"title": { "fontSize": 20, "fontWeight": "700" },
"subtitle": { "fontSize": 14, "color": "#666666" },
"image": { "aspectRatio": 2 }
},
"options": { "showSubtitle": true }
}Run the app pointed at the draft, verify the banner renders and the link navigates, then publish the draft when you are happy. instanceId distinguishes multiple copies of the same block on one screen: the same component can appear twice with different config.
Prefer doing this from the terminal (or letting your coding agent do it)? Config with milo walks the same placement as a pull → edit → commit → push loop.
Document the schema
Your block is only as configurable as its documentation. For every field the block reads, record the type, default, and what it does. Include a complete sample config (the JSON above) that can be pasted straight into the dashboard. Merchants, the Appbrew team, and future you all configure the block from this document rather than from your source.
Next
- Browse the UI elements catalog before composing your next block.
- Pull store-managed data into blocks with metafields.
- Run the verification loop before you hand the block off.
- Building blocks inside an integration package instead of an app repo? The component is identical; see Build an integration for where it lives and how it registers.

