AppbrewDevelopers
Build

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:

SectionPurpose
sourceData — what the block displays (text, URLs, ids, links)
styleVisuals — colors, spacing, typography, layout
optionsBehavior — 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.

src/app/blocks/promo-banner.tsx
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.

src/app/blocks/promo-banner.tsx
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 null with 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.

src/app/register-blocks.ts
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.

On this page