Build a Settings Screen
This guide is the canonical path for a real Craft plugin screen that saves settings through a React UI.
Use this approach when:
- the screen belongs to a Craft plugin
- the page already starts from PHP and Twig
- you want the fields to be defined as SchemaForm schema
- you want client-side validation, server-side validation, and a normal save action
For a settings-style screen, use SchemaForm. It keeps the field definition close to your PHP plugin code and gives the React side one clear job: render the schema, manage state, and post the values back to Craft.
What you are building
At the end of this guide, the flow looks like this:
- PHP renders a Twig template for the settings page.
- Twig outputs a mount element plus JSON payload.
- A CP asset bundle loads your compiled React entry.
- React boots with
configurePluginKitReact()andcreateCraftHostBridge(). useSchemaFormEngine()renders the settings form from aschemaIndex.- The submit handler posts values back to a Craft action.
- If the action returns validation errors, SchemaForm shows them on the correct fields.
Recommended file shape
my-plugin/
src/
controllers/
SettingsController.php
models/
SettingsModel.php
templates/
settings/index.twig
web/
assets/
cp/
MyPluginCpAsset.php
dist/
src/
cp.tsx
settings/
SettingsApp.tsxThis keeps the PHP page, Twig template, asset bundle, and React entry easy to follow.
1. Render the settings page from PHP
Create a controller action that renders the Twig template and passes the React payload into it.
<?php
namespace mynamespace\myplugin\controllers;
use Craft;
use craft\web\Controller;
use mynamespace\myplugin\Plugin;
use yii\web\Response;
class SettingsController extends Controller
{
protected array|int|bool $allowAnonymous = false;
public function actionIndex(): Response
{
$settings = Plugin::getInstance()->getSettings();
$schemaIndex = [
'schema' => [
[
'$field' => 'text',
'name' => 'apiKey',
'label' => Craft::t('my-plugin', 'API key'),
'instructions' => Craft::t('my-plugin', 'Paste the API key from your external service.'),
'required' => true,
],
[
'$field' => 'lightswitch',
'name' => 'syncEnabled',
'label' => Craft::t('my-plugin', 'Enable sync'),
],
[
'$field' => 'text',
'name' => 'syncEndpoint',
'label' => Craft::t('my-plugin', 'Sync endpoint'),
'if' => 'syncEnabled == true',
'validation' => 'required',
],
],
'fieldEntries' => [
[
'path' => 'apiKey',
'field' => [
'$field' => 'text',
'name' => 'apiKey',
'label' => Craft::t('my-plugin', 'API key'),
'instructions' => Craft::t('my-plugin', 'Paste the API key from your external service.'),
'required' => true,
],
],
[
'path' => 'syncEnabled',
'field' => [
'$field' => 'lightswitch',
'name' => 'syncEnabled',
'label' => Craft::t('my-plugin', 'Enable sync'),
],
],
[
'path' => 'syncEndpoint',
'field' => [
'$field' => 'text',
'name' => 'syncEndpoint',
'label' => Craft::t('my-plugin', 'Sync endpoint'),
'if' => 'syncEnabled == true',
'validation' => 'required',
],
],
],
];
return $this->renderTemplate('my-plugin/settings/index.twig', [
'reactPayload' => [
'schemaIndex' => $schemaIndex,
'values' => [
'apiKey' => $settings->apiKey,
'syncEnabled' => (bool)$settings->syncEnabled,
'syncEndpoint' => $settings->syncEndpoint,
],
],
]);
}
}This example keeps the schema in PHP so the settings page is still owned by the plugin backend.
2. Register a CP asset bundle
Create an asset bundle that points at your built frontend files.
<?php
namespace mynamespace\myplugin\web\assets\cp;
use craft\web\AssetBundle;
use craft\web\assets\cp\CpAsset;
class MyPluginCpAsset extends AssetBundle
{
public function init(): void
{
$this->sourcePath = '@mynamespace/myplugin/web/assets/cp/dist';
$this->depends = [
CpAsset::class,
];
$this->js = ['cp.js'];
$this->css = ['cp.css'];
parent::init();
}
}For this guide, keep the Vite output filenames stable so the asset bundle can reference them directly.
3. Render the mount element in Twig
Register the asset bundle and output one container with the serialized React payload.
{% do view.registerAssetBundle('mynamespace\\myplugin\\web\\assets\\cp\\MyPluginCpAsset') %}
<div
id="my-plugin-settings-root"
data-settings="{{ reactPayload | json_encode | e('html_attr') }}"
></div>That container is the contract between Twig and the React app.
4. Bootstrap the React entry
Create your CP entry file and configure the package for the Craft control panel.
import { createRoot } from 'react-dom/client';
import '@verbb/plugin-kit-react/style.css';
import { configurePluginKitReact, createCraftHostBridge } from '@verbb/plugin-kit-react/utils';
import { SettingsApp } from './settings/SettingsApp';
configurePluginKitReact({
hostBridge: createCraftHostBridge(),
translationCategory: 'my-plugin',
});
const container = document.getElementById('my-plugin-settings-root');
if (container) {
const payload = JSON.parse(container.dataset.settings ?? '{}');
createRoot(container).render(
<SettingsApp
schemaIndex={payload.schemaIndex}
initialValues={payload.values ?? {}}
/>,
);
}This is the standard CP bootstrap:
- load package CSS
- connect package helpers to Craft
- read the server payload from the mount element
- render the React screen
5. Build the settings screen with SchemaForm
Create the React screen that owns the SchemaForm instance and save flow.
import { useEffect, useState } from 'react';
import { Button } from '@verbb/plugin-kit-react/components';
import { SchemaFormEngine, useSchemaFormEngine } from '@verbb/plugin-kit-react/forms';
import { hostRequest } from '@verbb/plugin-kit-react/utils';
type SettingsAppProps = {
schemaIndex: {
schema: Record<string, unknown>[];
fieldEntries: Array<{ path: string; field: Record<string, unknown> }>;
};
initialValues: Record<string, unknown>;
};
export function SettingsApp({ schemaIndex, initialValues }: SettingsAppProps) {
const [serverErrors, setServerErrors] = useState<Record<string, string[]>>({});
const form = useSchemaFormEngine({
schemaIndex,
defaultValues: initialValues,
errors: serverErrors,
});
useEffect(() => {
form.onSubmit(async (values) => {
setServerErrors({});
try {
await hostRequest('POST', 'my-plugin/settings/save', {
data: values,
});
} catch (error) {
const responseErrors = (error as {
response?: { data?: { errors?: Record<string, string[]> } };
}).response?.data?.errors ?? {};
setServerErrors(responseErrors);
}
});
return () => {
form.onSubmit(null);
};
}, [form]);
return (
<div className="space-y-4">
<SchemaFormEngine form={form} className="grid grid-cols-1 gap-4" />
<div>
<Button type="button" variant="primary" onClick={() => { form.handleSubmit(); }}>
Save settings
</Button>
</div>
</div>
);
}This is the recommended shape for a plugin settings screen:
useSchemaFormEngine()owns the form stateSchemaFormEnginerenders the fieldshostRequest()posts to a Craft actionerrorshydrates server validation back into the UI
6. Return validation errors from the Craft action
Create an action that validates and saves the settings model.
public function actionSave(): Response
{
$this->requirePostRequest();
$plugin = Plugin::getInstance();
$settings = $plugin->getSettings();
$settings->apiKey = Craft::$app->getRequest()->getBodyParam('apiKey');
$settings->syncEnabled = (bool)Craft::$app->getRequest()->getBodyParam('syncEnabled');
$settings->syncEndpoint = Craft::$app->getRequest()->getBodyParam('syncEndpoint');
if (!$settings->validate()) {
return $this->asJson([
'success' => false,
'errors' => $settings->getErrors(),
]);
}
Craft::$app->getPlugins()->savePluginSettings($plugin, $settings->toArray());
return $this->asJson([
'success' => true,
]);
}The important part is the errors shape. SchemaForm expects a path-to-messages map, so returning $settings->getErrors() gives the React side something it can pass straight back into useSchemaFormEngine({ errors }).
7. What to do next
Once this screen works, keep building the settings page in the same direction:
- add more built-in SchemaForm fields under the same
schemaIndex - use conditional fields when part of the settings page depends on another value
- move repeated UI wrappers into custom schema components if the page grows
If the screen is still just a handful of bespoke React controls and does not benefit from PHP-defined schema, stop using this recipe and follow Compose a Form with Field Primitives instead.