Call To Action Signup
An email-capture call-to-action block — heading, copy, and a form that posts to a same-origin endpoint.
import type { Block } from 'payload'import { callToActionFields } from '@/blocks/shared/callToActionFields'import { validateSameOriginFormAction } from '@/blocks/shared/safeUrls'export const CallToActionSignup: Block = { slug: 'callToActionSignup', interfaceName: 'CallToActionSignupBlock', fields: [ // Shared call-to-action core (title, description). Variant-specific fields // follow; edit the shared shape in @/blocks/shared/callToActionFields. ...callToActionFields, { name: 'emailPlaceholder', type: 'text', }, { name: 'submitLabel', type: 'text', }, { name: 'action', type: 'text', validate: validateSameOriginFormAction, admin: { description: 'Same-origin path where the email form posts, such as /api/newsletter.', }, }, ], labels: { plural: 'Call To Action Signup Blocks', singular: 'Call To Action Signup', },}import React from 'react'import type { CallToActionSignupBlock as CallToActionSignupBlockData } from '@/payload-types'import { Mail, SendHorizonal } from 'lucide-react'import { getSafeFormAction } from '@/blocks/shared/safeUrls'import { Button } from '@/components/ui/button'import { cn } from '@/utilities/ui'type Props = CallToActionSignupBlockData & { id?: string className?: string disableInnerContainer?: boolean}export const CallToActionSignupBlock: React.FC<Props> = ({ action, className, description, disableInnerContainer, emailPlaceholder, id, submitLabel, title,}) => { const formAction = getSafeFormAction(action) ?? '#' return ( <section className={cn('container', className)} id={id ? `block-${id}` : undefined}> <div className="overflow-hidden rounded-frame border border-border/70 bg-card/35 px-6 py-10 sm:px-8 lg:px-12 lg:py-14"> <div className={cn('flex flex-col items-center gap-4 text-center', { 'mx-auto max-w-2xl': !disableInnerContainer, })} > <h2 className="text-4xl font-medium tracking-display text-balance sm:text-5xl">{title}</h2> {description ? ( <p className="text-base leading-7 text-muted-foreground sm:text-lg">{description}</p> ) : null} <form action={formAction} className="mt-2 w-full max-w-sm" method="post"> <div className="relative grid grid-cols-[1fr_auto] items-center rounded-frame border border-border/70 bg-background/80 pr-2"> <Mail className="pointer-events-none absolute inset-y-0 left-4 my-auto size-5 text-muted-foreground" /> <input aria-label={emailPlaceholder || 'Email address'} autoComplete="email" className="h-12 w-full bg-transparent pl-11 text-sm focus:outline-none" name="email" placeholder={emailPlaceholder || 'Your email address'} required type="email" /> <Button aria-label="Subscribe" type="submit"> <span className="hidden sm:block">{submitLabel || 'Get Started'}</span> <SendHorizonal className="size-4 sm:hidden" strokeWidth={2} /> </Button> </div> </form> </div> </div> </section> )}import type { Field } from 'payload'/** * Shared field core for the Call To Action component family. * * Every call-to-action variant (call-to-action-centered, call-to-action-boxed, * call-to-action-signup, …) spreads these heading fields first and then appends * its own variant-specific shape — the CTA link group for the centered and boxed * layouts, or the email-capture fields for the signup layout. Editing the shared * title/description here updates every installed call-to-action block at once, so * the family never drifts field-by-field across a repo. * * Installed once per repo at `src/blocks/shared/callToActionFields.ts`; re-running * `payload-components add call-to-action-*` never overwrites a copy you have already edited. */export const callToActionFields: Field[] = [ { name: 'title', type: 'text', required: true, }, { name: 'description', type: 'textarea', },]const approvedEmbedHosts = new Set([ 'airtable.com', 'docs.google.com', 'form.typeform.com', 'lookerstudio.google.com', 'maps.google.com', 'player.vimeo.com', 'vimeo.com', 'www.airtable.com', 'www.google.com', 'www.youtube-nocookie.com', 'www.youtube.com', 'youtube-nocookie.com', 'youtube.com',])const approvedEmbedHostSuffixes = [ '.airtable.com', '.google.com', '.typeform.com', '.vimeo.com', '.youtube-nocookie.com', '.youtube.com',]const embedUrlError = 'Use an approved HTTPS embed URL.'const formActionError = 'Use a same-origin path, such as /api/newsletter.'const sameOriginBase = 'https://payload-components.local'const isApprovedEmbedHost = (hostname: string) => { const normalizedHost = hostname.toLowerCase() return ( approvedEmbedHosts.has(normalizedHost) || approvedEmbedHostSuffixes.some((suffix) => normalizedHost.endsWith(suffix)) )}export const getSafeEmbedUrl = (value: unknown) => { if (typeof value !== 'string') return undefined const trimmed = value.trim() if (!trimmed) return undefined try { const parsed = new URL(trimmed) if (parsed.protocol !== 'https:' || parsed.username || parsed.password) { return undefined } if (!isApprovedEmbedHost(parsed.hostname)) { return undefined } return parsed.toString() } catch { return undefined }}export const validateEmbedUrl = (value: unknown) => getSafeEmbedUrl(value) ? true : embedUrlErrorexport const getSafeFormAction = (value: unknown) => { if (typeof value !== 'string') return undefined const trimmed = value.trim() if (!trimmed || !trimmed.startsWith('/') || trimmed.startsWith('//') || trimmed.includes('\\')) { return undefined } try { const parsed = new URL(trimmed, sameOriginBase) if (parsed.origin !== sameOriginBase) { return undefined } return `${parsed.pathname}${parsed.search}${parsed.hash}` } catch { return undefined }}export const validateSameOriginFormAction = (value: unknown) => { if (value === null || value === undefined || (typeof value === 'string' && value.trim() === '')) { return true } return getSafeFormAction(value) ? true : formActionError}Installation
npx payload-components add call-to-action-signupCopy the files straight from the registry, then wire the Payload fragments by hand:
pnpm dlx shadcn@latest add https://www.payload-components.xyz/r/call-to-action-signup.jsonWhat it installs
Copies 4 source files into your project:
src/blocks/shared/callToActionFields.tssharedsrc/blocks/shared/safeUrls.tssrc/blocks/CallToActionSignup/config.tssrc/blocks/CallToActionSignup/Component.tsx
…and makes 4 edits to wire the block into your project:
| Registers the block | src/collections/Pages/index.ts |
| Maps the renderer | src/blocks/RenderBlocks.tsx |
| Regenerates types | src/payload-types.ts |
| Regenerates the admin import map | src/app/(payload)/admin/importMap.js |
callToActionFields.ts is the shared field core for this family — every variant composes it. Editing it updates each installed block at once, and re-running an install never overwrites a copy you have changed.Re-running the install converges: it detects existing wiring, skips it, and records install state in .payload-components/state.json.
This variant also installs the shadcn button component and adds lucide-react for the form's mail and
send icons. The form is server-rendered — set action to a same-origin handler such as /api/newsletter.
Content model
title and description come from the shared callToActionFields base; the form fields are specific to
this variant.
Prop
Type
Usage
CallToActionSignup block to its layout.RenderBlocks on the frontend, fully typed — no extra wiring.Requirements
- Target
- payload-website-starter
- Payload
- v3
- Next.js
- 15 / 16
- shadcn UI
- button
Your project must already expose components.json, src/payload.config.ts, src/blocks/RenderBlocks.tsx, src/collections/Pages/index.ts — the surfaces payload-components add patches. The CLI verifies this against the support matrix before touching anything.
In this family
call-to-action-centeredcall-to-action-boxedcall-to-action-signupcurrent