@pro-laico/atomic
Turns the nested blocks editors build in the Payload admin into a working, interactive website: reusable content blocks, point-and-click actions, and a complete form pipeline.
@pro-laico/atomic turns what editors build in the Payload admin into a real, interactive website. It powers reusable content blocks you can nest inside one another, point-and-click interactivity like dialogs, popovers, and theme toggles, and a full form pipeline with built-in sanitation, validation, and rate limiting.
This is foundational tooling. You rarely install it on its own. The atomic-payload template and the other @pro-laico/* plugins bring it along, and a lot of its work happens behind the scenes when content is saved. But it also ships the React pieces your own app renders with (the block renderer, the form submission action, and the client store behind interactive blocks), so this page covers that frontend surface too.
@pro-laico/atomic is a Tool package, a building block of the Atomic Payload stack, meant to be used alongside @pro-laico/core and the other @pro-laico/* packages (most easily via the atomic-payload template), not on its own.
Installation
pnpm add @pro-laico/atomicnpm install @pro-laico/atomicyarn add @pro-laico/atomicpayload, next, react, react-dom, server-only, @payloadcms/ui, and @payloadcms/richtext-lexical are peers you already have in a Payload + Next.js app. unocss (CSS generation), zustand (the client store behind interactive blocks), and @mux/blurup (Mux video placeholders) are optional, so install them only if you use those features.
Setup
The package is organized into four areas you wire up in your Payload config: actions, the save-time hook, forms, and child blocks. Each registers independently, so you can add only the ones you need.
Wiring atomic into your own Payload + Next.js project.
@pro-laico/core comes bundled with this plugin — it's a dependency, not a separate install. Its cached reads reach Payload through a config you register once in your Next.js instrumentation hook (registerPayloadConfig); see @pro-laico/core → Setup to wire it up.
Register the block plugins
Add the actions, forms, and child-blocks plugins to your config. Each prepends its blocks to config.blocks; order matters, so keep them in this sequence (child blocks first in the merged list, then actions, then form blocks):
import { buildConfig } from 'payload'
import { formsPlugin } from '@pro-laico/atomic/forms'
import { actionsPlugin } from '@pro-laico/atomic/actions'
import { childBlocksPlugin } from '@pro-laico/atomic/children'
export default buildConfig({
plugins: [
formsPlugin(),
actionsPlugin(),
childBlocksPlugin(),
],
})childBlocksPlugin()registers the nestable content blocks editors build pages from (Atomic, SimpleText, RichText, Image, Video, Icon, SVG).actionsPlugin()registers the built-in action blocks (theme toggle, cookie consent, form submit, dynamic store, portal open).formsPlugin()registers the default submit-form processing blocks (rate limiting, sanitation, validation).
Add fields to the child blocks (optional)
The child blocks ship with no styling fields of their own, so they don't depend on @pro-laico/styles. Use blockFields (keyed by block slug) to prepend or append your own fields per block, for example the @pro-laico/styles ClassNameField so editors can type utility classes:
import { ClassNameField } from '@pro-laico/styles'
import { childBlocksPlugin } from '@pro-laico/atomic/children'
childBlocksPlugin({
blockFields: {
SVGChild: { prependFields: [ClassNameField({ label: 'SVG Atomic Classes' })] },
ImageChild: { prependFields: [ClassNameField({ label: 'Image Atomic Classes' })] },
// …any other block, any fields you like
},
})AtomicChild is a special case. It stays on a dedicated classNameField option rather than blockFields. Pass classNameField: ClassNameField to wire it up.
Attach the save-time hook
When a page is saved, atomic walks its blocks once to collect the styles, actions, and forms the frontend needs, generate the CSS, and revalidate caches. createAtomicHook builds that beforeChange hook from a CSS getter and the action storage processor; atomicHookPlugin attaches it to the collections you name. The CSS getter comes from createCssGetCached (in @pro-laico/styles/cache), which uses styles' own getters and the injected header / footer getters from @pro-laico/site/cache:
import { buildConfig } from 'payload'
import { createCssGetCached } from '@pro-laico/styles/cache'
import { getCachedFooter, getCachedHeader } from '@pro-laico/site/cache'
import { atomicHookPlugin, createAtomicHook } from '@pro-laico/atomic/hook'
import { ActionBlockStorageProcessor } from '@pro-laico/atomic/actions/processor'
const getCached = createCssGetCached({ getHeader: getCachedHeader, getFooter: getCachedFooter })
const atomicHook = createAtomicHook({ getCached, ActionBlockStorageProcessor })
export default buildConfig({
plugins: [
atomicHookPlugin({ hook: atomicHook, collectionSlugs: ['pages'] }),
],
})The ready-made atomicHook export wires those dependencies lazily for you (using the template's conventional slug names), so you can pass it straight through. That's what the template does (see the other tab).
Render the blocks on the frontend
RenderChildren walks a saved block tree and renders each block (Server Component). Pass it the children array from a page you fetched:
// app/(frontend)/[...slug]/page.tsx
import { RenderChildren } from '@pro-laico/atomic/children/render'
export default async function Page() {
const page = await getPage() // however you load your page
return (
<main>
<RenderChildren blocks={page.children} />
</main>
)
}Wrap your app with the client store
Interactive blocks (dialogs, popovers, theme toggles, the dynamic store) read their state from a client store. Wrap your frontend in AtomicStoreProvider, seeding it with the actions atomic stored for the page:
// app/(frontend)/layout.tsx
import { AtomicStoreProvider } from '@pro-laico/atomic/hook/client'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<AtomicStoreProvider initialState={storedAtomicActions}>{children}</AtomicStoreProvider>
</body>
</html>
)
}Use the form submission action
Forms rendered by RenderChildren submit through atomic's submitForm server action, which runs the stored rate-limiting, sanitation, and validation pipeline before writing the submission. Import it from its own subpath (kept separate so it isn't pulled into config evaluation):
import { submitForm } from '@pro-laico/atomic/forms/submitForm/serverFunction'For form submissions to reach the right URL in production, set the environment variables below.
The atomic-payload template already wires all of this: the plugins, the save-time hook, the renderer, the client store, and the form action. You usually don't touch any of it; you just build pages in the admin.
It's already registered
The template's plugin list adds formsPlugin, actionsPlugin, and childBlocksPlugin, and weaves the @pro-laico/styles ClassNameField into the child blocks for you. The save-time hook is the ready-made atomicHook, handed to @pro-laico/styles so it runs on the content collections.
import { atomicHook } from '@pro-laico/atomic/hook'
import { stylesPlugin } from '@pro-laico/styles'
stylesPlugin({ atomicHook /* …other options */ })Build pages in the admin
Open a page in the admin and assemble it from the child blocks (text, images, icons, rich text, video) and action blocks (theme toggle, cookie consent, form submit, dynamic store, portals). Saving runs the hook, which generates the styles and stores the actions and forms the frontend needs.
The frontend is already wired
The template's [...slug]/page.tsx renders pages with RenderChildren, its (frontend)/layout.tsx wraps the app in AtomicStoreProvider, and forms submit through the submitForm server action. Set NEXT_PUBLIC_SERVER_URL so submissions resolve correctly in production.
Caching & revalidation
Atomic ships server-side cache getters from @pro-laico/atomic/cache, wrapped so a page doesn't re-query Payload on every render:
getCachedAtomicActions(draft): the stored actions snapshot you seedAtomicStoreProviderwith.getCachedAtomicForms(draft)/getCachedBackendForms()/getCachedAllForms(draft, atomicForms, backendForms): the stored form definitions thesubmitFormaction reads before processing a submission.getCachedFormSubmissions(formTitle): a form's submissions.
The save-time atomicHook (the same one wired in Setup) revalidates the matching tags as it stores a page's data, including atomic-actions, atomic-forms, atomic-classes, pages, and the per-page page tag, so the next read serves fresh data and live preview updates. See Caching & revalidation for how the tags and withCache work.
Options
Each plugin factory takes its own options object. All accept enabled (set false to turn it off).
actionsPlugin(options?):
Prop
Type
All actionsPlugin options at their defaults:
actionsPlugin({
enabled: true,
actionBlocks: [], // no extra action blocks
})formsPlugin(options?):
Prop
Type
All formsPlugin options at their defaults:
formsPlugin({
enabled: true,
formBlocks: [], // no extra form-processing blocks
})childBlocksPlugin(options?):
Prop
Type
The blockFields option is keyed by child-block slug, and each entry takes the same { prependFields, appendFields } shape:
All childBlocksPlugin options at their defaults:
import { ClassNameField } from '@pro-laico/styles'
childBlocksPlugin({
enabled: true,
childBlocks: [], // no extra child blocks
blockFields: {}, // no per-block prepend/append fields
classNameField: ClassNameField, // optional; wires AtomicChild's class field
})atomicHookPlugin(options):
Prop
Type
All atomicHookPlugin options at their defaults:
import { atomicHook } from '@pro-laico/atomic/hook'
atomicHookPlugin({
enabled: true,
hook: atomicHook, // required (no default)
collectionSlugs: ['pages'], // required (no default)
})Environment variables
Atomic reads these when resolving the URL form submissions POST to. They're optional in development (it falls back to http://localhost:3000), but a production deploy should set one.
| Variable | Purpose |
|---|---|
NEXT_PUBLIC_SERVER_URL | The public URL of your site, used to build the form submission endpoint. |
VERCEL_PROJECT_PRODUCTION_URL | Fallback on Vercel when NEXT_PUBLIC_SERVER_URL isn't set; atomic prefixes it with https://. |
Exports
Atomic exposes most of its surface through namespaced subpaths (@pro-laico/atomic/{actions,hook,forms,children,cache} and their sub-subpaths). The package root re-exports the three block plugin factories (actionsPlugin, formsPlugin, childBlocksPlugin), the ChildrenBlocksField, and the ready-made atomicHook as a convenience; everything else lives on its subpath. The full subpath list is in the package's package.json exports. The key public pieces:
Plugins & options
Export
Type
childBlocksPluginplugin
Parameters
options?:ChildBlocksPluginOptionsSee the Options table above. blockFields prepends/appends fields per block; classNameField wires the AtomicChild class field.Returns
PluginA Payload config plugin you add to buildConfig({ plugins: [...] }).Example
import { buildConfig } from 'payload'import { ClassNameField } from '@pro-laico/styles'import { childBlocksPlugin } from '@pro-laico/atomic/children'export default buildConfig({plugins: [ childBlocksPlugin({ classNameField: ClassNameField, blockFields: { SVGChild: { prependFields: [ClassNameField({ label: 'SVG Atomic Classes' })] }, }, }),],})Location
@pro-laico/atomic/childrenactionsPluginplugin
Parameters
options?:ActionsPluginOptionsSee the Options table above. actionBlocks merges extra blocks after the defaults.Returns
PluginA Payload config plugin you add to buildConfig({ plugins: [...] }).Example
import { buildConfig } from 'payload'import { actionsPlugin } from '@pro-laico/atomic/actions'export default buildConfig({plugins: [actionsPlugin()],})Location
@pro-laico/atomic/actionsformsPluginplugin
Parameters
options?:FormsPluginOptionsSee the Options table above. formBlocks merges extra processing blocks after the defaults.Returns
PluginA Payload config plugin you add to buildConfig({ plugins: [...] }). Run it before actionsPlugin and childBlocksPlugin so block order stays correct.Example
import { buildConfig } from 'payload'import { formsPlugin } from '@pro-laico/atomic/forms'import { actionsPlugin } from '@pro-laico/atomic/actions'import { childBlocksPlugin } from '@pro-laico/atomic/children'export default buildConfig({plugins: [formsPlugin(), actionsPlugin(), childBlocksPlugin()],})Location
@pro-laico/atomic/formsatomicHookPluginplugin
Parameters
options:AtomicHookPluginOptionshook is the beforeChange hook to attach (the ready-made atomicHook or one from createAtomicHook); collectionSlugs are the collections that should run it on save.Returns
PluginA Payload config plugin that adds the hook to the listed collections.Example
import { buildConfig } from 'payload'import { atomicHook, atomicHookPlugin } from '@pro-laico/atomic/hook'export default buildConfig({plugins: [ atomicHookPlugin({ hook: atomicHook, collectionSlugs: ['pages'] }),],})Location
@pro-laico/atomic/hookChildBlocksPluginOptionstype
Location
@pro-laico/atomic/childrenActionsPluginOptionstype
Location
@pro-laico/atomic/actionsFormsPluginOptionstype
Location
@pro-laico/atomic/formsAtomicHookPluginOptionstype
Location
@pro-laico/atomic/hookChildren
Export
Type
RenderChildrencomponent
Parameters
blocks:Page['children']The saved block array from a page you fetched.Returns
Promise<JSX.Element>The rendered block tree (it is an async Server Component).Example
// app/(frontend)/[...slug]/page.tsximport { RenderChildren } from '@pro-laico/atomic/children/render'export default async function Page() {const page = await getPage() // however you load your pagereturn ( <main className={page.mainClassName || undefined}> <RenderChildren blocks={page.children} /> </main>)}Location
@pro-laico/atomic/children/renderbuildChildBlocksfunction
Parameters
options?:{ blockFields?, classNameField? }The same blockFields (per-block prepend/append) and classNameField the plugin accepts.Returns
Block[]The default child blocks (Atomic, SimpleText, RichText, Image, Video, Icon, SVG).Example
import { buildChildBlocks } from '@pro-laico/atomic/children'import { ClassNameField } from '@pro-laico/styles'// compose your own blocks field instead of using childBlocksPlugin:const blocks = buildChildBlocks({classNameField: ClassNameField,blockFields: { SVGChild: { prependFields: [ClassNameField()] } },})Location
@pro-laico/atomic/childrenchildBlocksobject
Location
@pro-laico/atomic/childrenChildrenBlocksFieldfield
children blocks field (a ready-made Payload blocks field, not a factory) that holds nested child blocks. Drop it into your own collection or global. Also re-exported from the root.Example
import type { CollectionConfig } from 'payload'import { ChildrenBlocksField } from '@pro-laico/atomic/children'export const Pages: CollectionConfig = {slug: 'pages',fields: [ { name: 'title', type: 'text' }, ChildrenBlocksField,],}Location
@pro-laico/atomic/childrenHook
Export
Type
createAtomicHookfunction
atomicHook.Parameters
options:CreateAtomicHookOptionsRequires getCached (build one with createCssGetCached from @pro-laico/styles/cache) and ActionBlockStorageProcessor (from @pro-laico/atomic/actions/processor). Optional slug-config keys (pagesSlug, designSetSlug, cssCacheTagBySlug, cssStorageGlobals, …) default to the template names.Returns
CollectionBeforeChangeHookAttach it to your content collections via atomicHookPlugin (or stylesPlugin).Example
import { createCssGetCached } from '@pro-laico/styles/cache'import { getCachedFooter, getCachedHeader } from '@pro-laico/site/cache'import { atomicHookPlugin, createAtomicHook } from '@pro-laico/atomic/hook'import { ActionBlockStorageProcessor } from '@pro-laico/atomic/actions/processor'const getCached = createCssGetCached({ getHeader: getCachedHeader, getFooter: getCachedFooter })const hook = createAtomicHook({ getCached, ActionBlockStorageProcessor })// in buildConfig:atomicHookPlugin({ hook, collectionSlugs: ['pages'] })Location
@pro-laico/atomic/hookatomicHookfunction
Parameters
args:BeforeChangeHookArgsPayload's beforeChange hook arguments. You normally don't call this yourself; you hand it to a plugin.Returns
Promise<unknown>The processed document data (it is a CollectionBeforeChangeHook).Example
import { atomicHook, atomicHookPlugin } from '@pro-laico/atomic/hook'// hand it to the plugin (or to stylesPlugin({ atomicHook })):atomicHookPlugin({ hook: atomicHook, collectionSlugs: ['pages'] })Location
@pro-laico/atomic/hookunsetActivefunction
Parameters
args:{ id, draft, req, slug }The doc id being activated, the draft flag, the Payload request, and the collection slug.Returns
Promise<string | undefined>The slug of the doc that was unset (so the hook can revalidate it), or undefined.Example
import { unsetActive } from '@pro-laico/atomic/hook'// inside a beforeChange hook, when data.active is true:const unsetSlug = await unsetActive({ id, draft, req, slug: collection.slug })Location
@pro-laico/atomic/hookAtomicStoreProvidercomponent
Parameters
initialState:AtomicStoreInitialStateThe stored actions snapshot, from getCachedAtomicActions(draft).children:React.ReactNodeYour app tree.Returns
JSX.ElementA context provider wrapping children.Example
// app/(frontend)/layout.tsximport { draftMode } from 'next/headers'import { getCachedAtomicActions } from '@pro-laico/atomic/cache'import { AtomicStoreProvider } from '@pro-laico/atomic/hook/client'export default async function RootLayout({ children }: { children: React.ReactNode }) {const { isEnabled: draft } = await draftMode()const storedAtomicActions = await getCachedAtomicActions(draft)return ( <html lang="en"> <body> <AtomicStoreProvider initialState={storedAtomicActions}>{children}</AtomicStoreProvider> </body> </html>)}Location
@pro-laico/atomic/hook/clientuseAtomicStorefunction
AtomicStoreProvider.Parameters
selector:(store: AtomicStore) => TSelects the slice of store state you want.Returns
TThe selected value, re-rendering when it changes.Example
'use client'import { useAtomicStore } from '@pro-laico/atomic/hook/client'export function ThemeLabel() {const theme = useAtomicStore((store) => store.getValue('theme', false))return <span>{String(theme)}</span>}Location
@pro-laico/atomic/hook/clientForms
Export
Type
submitFormfunction
Parameters
submissionData:SubmitFormPropsformData (the FormData), submissionID (a unique id you generate), blockID (the atomic form block id), and clientData (timezone / screen / preferences).Returns
Promise<FormResponse>{ success, fm (form message), im (per-input messages), … }.Example
'use client'import { submitForm } from '@pro-laico/atomic/forms/submitForm/serverFunction'export function ContactForm({ blockID }: { blockID: string }) {async function onSubmit(e: React.FormEvent<HTMLFormElement>) { e.preventDefault() const formData = new FormData(e.currentTarget) const clientData = { timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, screenWidth: `${window.screen.width}`, screenHeight: `${window.screen.height}`, preferences: {}, } const res = await submitForm({ blockID, formData, submissionID: Date.now().toString(), clientData }) if (!res.success) console.error(res.fm)}return <form onSubmit={onSubmit}>{/* inputs */}</form>}Location
@pro-laico/atomic/forms/submitForm/serverFunctiondefaultSubmitFormBlocksobject
Location
@pro-laico/atomic/formsSubmitFormFunctiontype
Location
@pro-laico/atomic/formsActions
Export
Type
AllActionBlocksobject
Location
@pro-laico/atomic/actionsActionOptionsobject
Location
@pro-laico/atomic/actionsActionFiltersobject
Location
@pro-laico/atomic/actionsActionBlockStorageProcessorclass
Parameters
(constructor):() => ActionBlockStorageProcessorTakes no arguments. createAtomicHook calls new ActionBlockStorageProcessor() once per save.Returns
ActionBlockStorageProcessorAn instance with before / after / getAllActionBlocks methods the hook drives as it traverses the tree.Example
import { createAtomicHook } from '@pro-laico/atomic/hook'import { ActionBlockStorageProcessor } from '@pro-laico/atomic/actions/processor'// pass the class through to the hook builder; it instantiates it per save:const hook = createAtomicHook({ getCached, ActionBlockStorageProcessor })Location
@pro-laico/atomic/actions/processorCache getters
Server-side reads from @pro-laico/atomic/cache, revalidated by the save-time atomicHook. See Caching & revalidation.
Export
Type
getCachedAtomicActionsfunction
Parameters
draft:booleanRead the draft (true) or published (false) variant.Returns
Promise<AtomicStoreInitialState>The stored actions snapshot.Example
import { draftMode } from 'next/headers'import { getCachedAtomicActions } from '@pro-laico/atomic/cache'import { AtomicStoreProvider } from '@pro-laico/atomic/hook/client'// app/(frontend)/layout.tsxconst { isEnabled: draft } = await draftMode()const storedAtomicActions = await getCachedAtomicActions(draft)return <AtomicStoreProvider initialState={storedAtomicActions}>{children}</AtomicStoreProvider>Location
@pro-laico/atomic/cachegetCachedAtomicFormsfunction
createGetCachedAtomicForms(slug) binds it to a non-default pages collection.Parameters
draft:booleanDraft or published variant.Returns
Promise<StoredAtomicForm[]>The stored atomic forms.Example
import { getCachedAtomicForms, createGetCachedAtomicForms } from '@pro-laico/atomic/cache'const forms = await getCachedAtomicForms(false)// or bind a non-default pages slug:const getArticleForms = createGetCachedAtomicForms('articles')Location
@pro-laico/atomic/cachegetCachedBackendFormsfunction
forms collection). createGetCachedBackendForms(slug) binds it to a non-default forms collection.Parameters
Returns
Promise<Form[]>Every backend form definition.Example
import { getCachedBackendForms } from '@pro-laico/atomic/cache'const backendForms = await getCachedBackendForms()Location
@pro-laico/atomic/cachegetCachedAllFormsfunction
Parameters
draft:booleanDraft or published variant.atomicForms:StoredAtomicForm[]From getCachedAtomicForms.backendForms:Form[]From getCachedBackendForms.Returns
Promise<ModifiedStoredAtomicForm[]>Atomic forms with their resolved backendFormID.Example
import { getCachedAllForms, getCachedAtomicForms, getCachedBackendForms } from '@pro-laico/atomic/cache'const backendForms = await getCachedBackendForms()const atomicForms = await getCachedAtomicForms(false)const allForms = await getCachedAllForms(false, atomicForms, backendForms)Location
@pro-laico/atomic/cachegetCachedFormSubmissionsfunction
Parameters
formTitle:stringThe title of the backend form whose submissions you want.Returns
Promise<FormSubmission[]>The submissions stored for that form.Example
import { getCachedFormSubmissions } from '@pro-laico/atomic/cache'const submissions = await getCachedFormSubmissions('Contact')Location
@pro-laico/atomic/cacheTypes
Export
Type
Schema typestype
Location
@pro-laico/atomic/{actions,hook,forms,children}/schemaRelated
@pro-laico/tracking
Turn analytics on and off from the Payload admin: flip a switch for PostHog, Google Tag Manager, or Vercel Analytics and the right scripts load on your site.
@pro-laico/site
The ready-made site shape for Atomic Payload: Pages, Header, and Footer collections plus site-wide Settings and SEO metadata, all from one sitePlugin().