Docs

This page isn't translated yet

next

withSysthema()

Wrap buildConfig(), import paths, 'use client' imports, and discovering Systhema from another plugin.

On this page

Wrap your buildConfig() call with withSysthema(). Systhema deep-merges its defaults under your overrides.

src/payload.config.ts
import { sqliteAdapter } from '@payloadcms/db-sqlite'
import {
  withSysthema,
  type SysthemaPayloadPluginOptions,
} from '@systhemaui/payload'
import { buildConfig } from 'payload'

const userSysthemaConfig: SysthemaPayloadPluginOptions = {
  // see "Plugin options" below
}

export default buildConfig(
  withSysthema(
    {
      admin: {
        dateFormat: 'yyyy-MM-dd HH:mm:ss',
      },
      secret: process.env.PAYLOAD_SECRET || '',
      db: sqliteAdapter({
        client: { url: process.env.DATABASE_URI || '' },
      }),
    },
    userSysthemaConfig,
  ),
)

You can let Systhema generate this file for you with:

pnpm systhema-core payload create-config

For every entry point and what it exports, see Payload import paths.

Imports in 'use client' filesLink to this section

Everything listed on Payload import paths is server surface. A 'use client' file — yours or one Systhema renders — must be pickier, because a single runtime import can drag a server-only module graph into the browser bundle. Three rules:

In a 'use client' file, import…FromNever from
Systhema config, resolved token values@systhemaui/core/client@systhemaui/core
Pure helpers (toKebabCase, toUrlCase, deepMerge, …)@systhemaui/core/utils@systhemaui/core
RichText, RenderForm, PostsListClient, ArchiveFilterProvider, Post*@systhemaui/payload/next/client@systhemaui/payload or @systhemaui/payload/next
registerSysthemaClientBlocks, registerSysthemaClientTextStates, types CustomBlock / CustomBlockConverter / CustomTextState@systhemaui/payload/next/client@systhemaui/payload
resolveIconHtml, resolveIconForRender@systhemaui/payload/next/client@systhemaui/payload/fields
useSysthemaIconResolution@systhemaui/payload/next/client—
  • @systhemaui/core's main entry statically reaches the design-token dataset (14 DTCG JSON files, >1 MB in a real project). JSON modules are whole-module, so one value import ships all of it. See @systhemaui/core/client.
  • Resolving an icon is client-safe; defining the picker is not. resolveIconHtml reads an in-memory cache the server fills, so it works in the browser. iconPickerField from @systhemaui/payload/fields builds an admin schema. A custom block that keeps its fields and its converter in one module puts both in the browser bundle the moment you register the converter for client-mode live preview — split the converter into its own module.
  • That in-memory cache is empty in the browser, so Material Symbols and Apple emoji picks resolve to nothing there. Client-mode live preview fetches the handful of ids a page uses from GET <routes.api>/systhema/icons/resolve and re-renders; a hand-rolled browser-rendered tree calls useSysthemaIconResolution(apiRoute) itself. See Icons → Rendering icons in the browser.
  • The @systhemaui/payload and @systhemaui/payload/next barrels reach the Payload admin graph — payload, @payloadcms/ui, @payloadcms/next and their translation catalogue for every supported language, plus the icon-pack fs runtime. @systhemaui/payload/next/client is barrel-free and exists precisely for this.

Systhema's own client-reachable modules follow these rules, and a package test walks the import graph — over src/ and the built dist/ — to keep it that way.

The same guard covers two things that are client-safe but optional, so a static import would make every visitor pay for a feature their site may not use:

  • Country / state option lists. The country and state form fields render ~10 KB of fixed names. They are resolved on the server and reach the browser as a serialized prop, so only a page that actually renders one of those fields carries the list — in its flight payload, never in a JS chunk. (Both field types are off by default; enable them via forms.fields.)
  • The built-in Posts page/post templates. Their client-side live-preview views load behind import(…), so the Posts presentation graph stays out of the bundle on sites with the Posts module off, and out of the published page everywhere.

RenderForm is a Server Component for the first of those reasons — imported from @systhemaui/payload/next it resolves the option lists on the server and hands them to the client form, which is where you want it. The client boundary is inside it.

It is also exported from @systhemaui/payload/next/client, because a page whose template renders a form otherwise could not have a clientView at all — it would be stuck on server-mode live preview while the rest of the site used client mode. Its graph is client-safe in full. The one thing that does not survive the crossing is the option-list read: in the browser the server-side store is empty by construction, so a country or state field renders an empty select. Resolve the lists in a server module and thread them back:

// The template's resolveData hook — a SERVER module.
import { resolveFormFieldOptions } from '@systhemaui/payload/next'

export const resolveData = async ({ data }: PageTemplateDataContext) => ({
  fieldOptions: resolveFormFieldOptions(data.form),
})
'use client'
import { RenderForm } from '@systhemaui/payload/next/client'

export default function EntryClientView({ data, aux }: LivePreviewClientView) {
  return <RenderForm form={data.form} fieldOptions={aux.fieldOptions} />
}

Omit fieldOptions on the server — the store is read directly — and omit it in a client view whose forms use neither field type (both are off by default).

From another pluginLink to this section

Systhema's plugin registers itself under the slug systhema, so any other plugin in the same config can read the options Systhema was configured with instead of parsing systhema.config.ts a second time.

import type { Plugin } from 'payload'

const myPlugin: Plugin = (config) => {
  const systhema = (config.plugins ?? []).find((plugin) => plugin.slug === 'systhema')
  const postsEnabled = Boolean(systhema?.options?.posts)
  // ...
  return config
}

Payload builds that lookup for you when the plugin is authored with definePlugin, which hands your plugin a slug-keyed plugins map alongside config. @systhemaui/payload augments Payload's RegisteredPlugins interface, so plugins.systhema is typed and its options need no cast:

import { definePlugin } from 'payload'

export const myPlugin = definePlugin({
  slug: 'my-plugin',
  plugin: ({ config, plugins }) => {
    const postsEnabled = Boolean(plugins.systhema?.options.posts)
    return config
  },
})

Two things to know. The options you read back are the normalized ones withSysthema() resolved, not the literal object you passed it. And Systhema leaves order unset, so it keeps its array position: withSysthema() registers it first, ahead of the plugins it configures.

Payload marks slug, order and options experimental, so treat this API as a moving target across Payload minors.