# Custom blocks (/docs/build/custom-blocks)



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.

<Callout title="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.
</Callout>

## How a block's config is shaped [#how-a-blocks-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.

<Steps>
  <Step>
    ### Define the config interface [#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.

    ```ts title="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.
  </Step>

  <Step>
    ### Write the component [#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](/docs/build/atoms-catalog)) rather than raw React Native primitives.

    ```tsx title="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.
  </Step>

  <Step>
    ### Register the block [#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](/docs/build/extending-app-behavior) from `@gauntlet/brewery` (platform 0.27.0 and later), and `blockRegistry`, which works on every version.

    <Tabs items="[&#x22;app object (0.27+)&#x22;, &#x22;blockRegistry (all versions)&#x22;]">
      <Tab value="app object (0.27+)">
        ```ts title="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](/docs/build/extending-app-behavior).
      </Tab>

      <Tab value="blockRegistry (all versions)">
        ```ts title="src/app/register-blocks.ts"
        import { blockRegistry, registerCommonBlocks } from '@gauntlet/block-registry'
        import { PromoBanner } from './blocks/promo-banner'

        export function registerBlocks() {
          const r = blockRegistry
          registerCommonBlocks(r)
          r.set('promo-banner', PromoBanner)
        }
        ```

        This is the form the app scaffold generates today. It works on every
        platform version, 0.27 included. Order matters: `registerCommonBlocks(r)`
        goes first, so a deliberate override of a common id by yours wins, and an
        accidental one is at least deterministic.
      </Tab>
    </Tabs>
  </Step>

  <Step>
    ### Place it on a screen [#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:

    ```json
    {
      "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](/docs/build/config-with-milo) walks the same placement as a pull → edit → commit → push loop.
  </Step>

  <Step>
    ### Document the schema [#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.
  </Step>
</Steps>

## Next [#next]

* Browse the [UI elements catalog](/docs/build/atoms-catalog) before composing your next block.
* Pull store-managed data into blocks with [metafields](/docs/build/metafields).
* Run the [verification loop](/docs/build/testing) 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](/docs/build/integrations) for where it lives and how it registers.
