Blockstudio

Full-Stack Blocks

Blockstudio 7.1 lets you build complete applications inside a block folder. A single directory can contain the UI, server logic, data model, scheduled tasks, and even the data itself.

This guide builds two real applications to show the pattern: a per-user todo app and a public newsletter signup. Same file structure, different access models.

Todo App

A per-user task manager. Each logged-in user sees only their own todos. Uses userScoped: true for automatic user isolation, SQLite for portable storage, bs.mutate() for optimistic updates, and realtime polling so changes from other tabs appear automatically.

File structure

blockstudio/
  todo/
    block.json
    index.php
    script.inline.js
    style.css
    db.php
    rpc.php
    cron.php

Block definition

block.json
{
  "$schema": "https://blockstudio.dev/schema/block",
  "name": "my-theme/todo",
  "title": "Todo App",
  "category": "widgets",
  "icon": "editor-ul",
  "supports": {
    "interactivity": true
  },
  "blockstudio": {
    "interactivity": {
      "enqueue": true
    }
  }
}

supports.interactivity enables WordPress's directive processing (SSR for data-wp-each, data-wp-bind, etc.). interactivity.enqueue loads the @wordpress/interactivity module on the frontend.

Data model

userScoped: true automatically adds a user_id column, sets it on create, and filters all queries to the current user. realtime enables automatic polling so changes from other tabs or devices appear without a page refresh.

db.php
<?php
return [
    'storage'    => 'sqlite',
    'userScoped' => true,
    'realtime'   => [
        'key'      => 'todos',
        'interval' => 3000,
    ],
    'capability' => [
        'create' => true,
        'read'   => true,
        'update' => true,
        'delete' => true,
    ],
    'fields' => [
        'text' => [
            'type'      => 'string',
            'required'  => true,
            'minLength' => 1,
            'maxLength' => 500,
        ],
        'done' => [
            'type'    => 'boolean',
            'default' => false,
        ],
    ],
];

SQLite storage means the database file lives at todo/db/default.sqlite. Copy the block folder to another site and the data comes with it.

The realtime config tells the client to poll for changes every 3 seconds using a lightweight hash comparison. When data changes, state.todos is updated and the UI re-renders automatically.

Custom server logic

Since userScoped auto-filters to the current user, the built-in bs.db().list() already returns only the current user's records. RPC is only needed for operations beyond CRUD: toggling and bulk clearing.

rpc.php
<?php
use Blockstudio\Db;

return [
    'toggle' => [
        'callback' => function (array $params) {
            $id = (int) ($params['id'] ?? 0);

            if (!$id) {
                return new \WP_Error('missing_id', 'ID is required.', ['status' => 400]);
            }

            $db   = Db::get('my-theme/todo');
            $todo = $db->get_record($id);

            if (!$todo) {
                return new \WP_Error('not_found', 'Not found.', ['status' => 404]);
            }

            return $db->update($id, ['done' => !$todo['done']]);
        },
        'public'  => true,
        'methods' => ['POST'],
    ],
    'clear_done' => [
        'callback' => function () {
            $db    = Db::get('my-theme/todo');
            $todos = $db->list();
            $count = 0;

            foreach ($todos as $todo) {
                if (!empty($todo['done'])) {
                    $db->delete((int) $todo['id']);
                    ++$count;
                }
            }

            return ['deleted' => $count];
        },
        'public'  => true,
        'methods' => ['POST'],
    ],
];

Scheduled cleanup

Old completed todos get cleaned up automatically.

cron.php
<?php
use Blockstudio\Db;

return [
    'cleanup_old_todos' => [
        'schedule' => 'daily',
        'callback' => function () {
            $db    = Db::get('my-theme/todo');
            $todos = $db->list();

            foreach ($todos as $todo) {
                $is_done = $todo['done'];
                $is_old  = strtotime($todo['updated_at']) < strtotime('-30 days');

                if ($is_done && $is_old) {
                    $db->delete((int) $todo['id']);
                }
            }
        },
    ],
];

Template

PHP loads the initial todos server-side via wp_interactivity_state(), which provides the data for both SSR directive processing and client-side hydration. Per-instance UI state (like the input field value) goes in data-wp-context.

index.php
<?php
$db         = \Blockstudio\Db::get('my-theme/todo');
$todos      = $db ? $db->list() : [];
$items_left = count(array_filter($todos, fn($t) => empty($t['done'])));

wp_interactivity_state('my-theme/todo', [
    'todos'     => array_values($todos),
    'hasTodos'  => !empty($todos),
    'hasDone'   => count($todos) !== $items_left,
    'itemsLeft' => $items_left . ($items_left === 1 ? ' item left' : ' items left'),
]);
?>
<div
    data-wp-interactive="my-theme/todo"
    data-wp-context='<?php echo esc_attr(wp_json_encode(['newText' => ''])); ?>'
    useBlockProps
    class="todo-app"
>
    <h2>Todos</h2>

    <div class="todo-input">
        <input
            type="text"
            placeholder="What needs to be done?"
            data-wp-bind--value="context.newText"
            data-wp-on--input="my-theme/todo::actions.updateNewText"
            data-wp-on--keydown="my-theme/todo::actions.handleKeyDown"
        />
        <button
            data-wp-on--click="my-theme/todo::actions.addTodo"
            data-wp-bind--disabled="!state.canAdd"
        >Add</button>
    </div>

    <ul class="todo-list">
        <template data-wp-each--todo="state.todos" data-wp-each-key="context.todo.id">
            <li>
                <label>
                    <input
                        type="checkbox"
                        data-wp-bind--checked="context.todo.done"
                        data-wp-on--change="actions.toggleTodo"
                    />
                    <span
                        data-wp-text="context.todo.text"
                        data-wp-class--done="context.todo.done"
                    ></span>
                </label>
                <button
                    class="todo-delete"
                    data-wp-on--click="actions.deleteTodo"
                >&times;</button>
            </li>
        </template>
    </ul>

    <div class="todo-footer" data-wp-bind--hidden="!state.hasTodos">
        <span data-wp-text="state.itemsLeft"></span>
        <button
            data-wp-on--click="my-theme/todo::actions.clearDone"
            data-wp-bind--hidden="!state.hasDone"
        >Clear done</button>
    </div>
</div>

Key patterns:

  • wp_interactivity_state() for data that comes from the server (todos, counts)
  • data-wp-context for ephemeral UI state (input text)
  • data-wp-each--todo gives each iteration item a named context (context.todo)
  • state.* references the global store, context.* references per-instance data

Client logic

bs.mutate() handles all writes with optimistic updates and automatic rollback. The UI updates instantly; if the server call fails, the state reverts.

script.inline.js
import { store, getContext } from '@wordpress/interactivity';

const { state } = store('my-theme/todo', {
    state: {
        get canAdd() {
            return getContext().newText.trim() !== '';
        },
        get hasTodos() {
            return state.todos.length > 0;
        },
        get hasDone() {
            return state.todos.some(t => t.done);
        },
        get itemsLeft() {
            const count = state.todos.filter(t => !t.done).length;
            return count + (count === 1 ? ' item left' : ' items left');
        },
    },
    actions: {
        updateNewText(event) {
            getContext().newText = event.target.value;
        },
        handleKeyDown(event) {
            if (event.key === 'Enter') {
                event.preventDefault();
                store('my-theme/todo').actions.addTodo();
            }
        },
        *addTodo() {
            const ctx = getContext();
            const text = ctx.newText.trim();
            if (!text) return;
            ctx.newText = '';

            yield bs.mutate({
                fn: () => bs.db('my-theme/todo').create({ text, done: false }),
                state, key: 'todos', action: 'create',
                optimistic: { text, done: false },
            });
        },
        *toggleTodo() {
            const todo = getContext().todo;

            yield bs.mutate({
                fn: () => bs.fn('toggle', { id: todo.id }, 'my-theme/todo'),
                state, key: 'todos', action: 'update', id: todo.id,
                optimistic: { ...todo, done: !todo.done },
            });
        },
        *deleteTodo() {
            const todo = getContext().todo;

            yield bs.mutate({
                fn: () => bs.db('my-theme/todo').delete(todo.id),
                state, key: 'todos', action: 'delete', id: todo.id,
            });
        },
        *clearDone() {
            yield bs.mutate({
                fn: () => bs.fn('clear_done', {}, 'my-theme/todo'),
                before() {
                    const snap = state.todos.slice();
                    state.todos = state.todos.filter(t => !t.done);
                    return snap;
                },
                onError(err, snap) { state.todos = snap; },
                invalidate: 'todos',
            });
        },
    },
});

Key patterns:

  • bs.mutate() auto mode for create/update/delete: pass state, key, action, and optimistic data. The UI updates immediately, the server call happens in the background, and the state rolls back on failure.
  • bs.mutate() manual mode for clearDone: before() takes a snapshot and applies the change, onError() restores from the snapshot.
  • Generator functions (*addTodo) with yield for async operations in the Interactivity API.
  • bs.db() for direct CRUD, bs.fn() for custom server functions.

Styles

style.css
.todo-app {
    max-width: 500px;
    margin: 0 auto;
    font-family: system-ui, sans-serif;
}

.todo-input {
    display: flex;
    gap: 0.5rem;
    margin-bottom: 1rem;
}

.todo-input input {
    flex: 1;
    padding: 0.5rem 0.75rem;
    border: 1px solid #d1d5db;
    border-radius: 0.375rem;
}

.todo-input button {
    padding: 0.5rem 1rem;
    background: #3b82f6;
    color: white;
    border: none;
    border-radius: 0.375rem;
    cursor: pointer;
}

.todo-list {
    list-style: none;
    padding: 0;
}

.todo-list li {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 0.75rem 0;
    border-bottom: 1px solid #f3f4f6;
}

.todo-list label {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    flex: 1;
    cursor: pointer;
}

.todo-list .done {
    text-decoration: line-through;
    color: #9ca3af;
}

.todo-delete {
    background: none;
    border: none;
    color: #ef4444;
    font-size: 1.25rem;
    cursor: pointer;
    opacity: 0;
    transition: opacity 0.15s;
}

.todo-list li:hover .todo-delete {
    opacity: 1;
}

.todo-footer {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 0.75rem 0;
    font-size: 0.875rem;
    color: #6b7280;
}

.todo-footer button {
    background: none;
    border: none;
    color: #6b7280;
    cursor: pointer;
    text-decoration: underline;
}

CLI

wp bs db list my-theme/todo
wp bs db create my-theme/todo --text="Buy milk" --user_id=1
wp bs cron run my-theme/todo cleanup_old_todos
wp bs rpc call my-theme/todo toggle --id=1

Newsletter Signup

A public subscription form. Anyone can subscribe, only admins can view and manage subscribers. No userScoped, no authentication for the main action. Uses MySQL table storage for production use.

File structure

blockstudio/
  newsletter/
    block.json
    index.php
    style.css
    script.inline.js
    db.php
    rpc.php
    cron.php

Block definition

block.json
{
  "$schema": "https://blockstudio.dev/schema/block",
  "name": "my-theme/newsletter",
  "title": "Newsletter Signup",
  "category": "widgets",
  "icon": "email",
  "supports": {
    "interactivity": true
  },
  "blockstudio": {
    "interactivity": {
      "enqueue": true
    }
  }
}

Data model

Public create with CSRF protection. Custom validation blocks disposable emails. Admin-only for read/delete.

db.php
<?php
return [
    'storage'    => 'table',
    'capability' => [
        'create' => true,
        'read'   => 'manage_options',
        'update' => 'manage_options',
        'delete' => 'manage_options',
    ],
    'fields' => [
        'email' => [
            'type'     => 'string',
            'format'   => 'email',
            'required' => true,
            'validate' => function ($value) {
                $blocked = ['mailinator.com', 'tempmail.com', 'throwaway.email'];
                $domain  = substr($value, strpos($value, '@') + 1);
                if (in_array($domain, $blocked, true)) {
                    return 'Please use a permanent email address.';
                }
                return true;
            },
        ],
        'name' => [
            'type'      => 'string',
            'maxLength' => 100,
        ],
        'status' => [
            'type'    => 'string',
            'enum'    => ['active', 'unsubscribed'],
            'default' => 'active',
        ],
    ],
    'hooks' => [
        'after_create' => function ($params) {
            $name = $params['record']['name'];
            wp_mail(
                $params['record']['email'],
                'Welcome!',
                'Thanks for subscribing' . ($name ? ", $name" : '') . '.'
            );
        },
    ],
];

Custom logic

A public unsubscribe endpoint (works from email links without login) and admin-only stats:

rpc.php
<?php
use Blockstudio\Db;

return [
    'unsubscribe' => [
        'callback' => function (array $params): array {
            $email = sanitize_email($params['email'] ?? '');
            $db    = Db::get('my-theme/newsletter');
            $subs  = $db->list(['email' => $email]);

            if (empty($subs)) {
                return ['error' => 'Email not found.'];
            }

            $db->update((int) $subs[0]['id'], ['status' => 'unsubscribed']);

            return ['success' => true];
        },
        'public' => true,
    ],
    'stats' => [
        'callback'   => function (array $params): array {
            $db = Db::get('my-theme/newsletter');
            return [
                'total'  => count($db->list()),
                'active' => count($db->list(['status' => 'active'])),
            ];
        },
        'capability' => 'manage_options',
    ],
];

Scheduled cleanup

cron.php
<?php
use Blockstudio\Db;

return [
    'cleanup' => [
        'schedule' => 'weekly',
        'callback' => function () {
            $db    = Db::get('my-theme/newsletter');
            $unsub = $db->list(['status' => 'unsubscribed']);

            foreach ($unsub as $row) {
                if (strtotime($row['updated_at']) < strtotime('-90 days')) {
                    $db->delete((int) $row['id']);
                }
            }
        },
    ],
];

Template

index.php
<?php
wp_interactivity_state('my-theme/newsletter', [
    'submitted' => false,
]);
?>
<div
    data-wp-interactive="my-theme/newsletter"
    data-wp-context='<?php echo esc_attr(wp_json_encode([
        'email'   => '',
        'name'    => '',
        'loading' => false,
        'error'   => '',
    ])); ?>'
    useBlockProps
    class="newsletter"
>
    <div data-wp-bind--hidden="state.submitted">
        <h3>Stay in the loop</h3>
        <div class="newsletter-form">
            <input
                type="text"
                placeholder="Name (optional)"
                data-wp-bind--value="context.name"
                data-wp-on--input="actions.setName"
            />
            <input
                type="email"
                placeholder="Email address"
                data-wp-bind--value="context.email"
                data-wp-on--input="actions.setEmail"
                data-wp-on--keydown="actions.handleKeydown"
            />
            <button
                data-wp-on--click="actions.subscribe"
                data-wp-bind--disabled="context.loading"
            >
                <span data-wp-text="context.loading ? 'Subscribing...' : 'Subscribe'"></span>
            </button>
        </div>
        <p data-wp-bind--hidden="!context.error" class="newsletter-error" data-wp-text="context.error"></p>
    </div>
    <div data-wp-bind--hidden="!state.submitted">
        <p class="newsletter-success">You're in! Check your email.</p>
    </div>
</div>

Client logic

script.inline.js
import { store, getContext } from '@wordpress/interactivity';

const { state } = store('my-theme/newsletter', {
    actions: {
        setName(event) {
            getContext().name = event.target.value;
        },
        setEmail(event) {
            getContext().email = event.target.value;
        },
        handleKeydown(event) {
            if (event.key === 'Enter') {
                store('my-theme/newsletter').actions.subscribe();
            }
        },
        *subscribe() {
            const ctx = getContext();
            const email = ctx.email.trim();
            if (!email) return;

            ctx.loading = true;
            ctx.error = '';

            const result = yield bs.db('my-theme/newsletter').create({
                email,
                name: ctx.name.trim(),
            });

            if (result.data?.errors) {
                ctx.error = Object.values(result.data.errors)[0][0];
                ctx.loading = false;
                return;
            }

            if (result.code) {
                ctx.error = result.message || 'Something went wrong.';
                ctx.loading = false;
                return;
            }

            state.submitted = true;
            ctx.loading = false;
        },
    },
});

Styles

style.css
.newsletter {
    max-width: 480px;
    margin: 0 auto;
    padding: 2rem;
    text-align: center;
}

.newsletter h3 {
    margin: 0 0 1rem;
}

.newsletter-form {
    display: flex;
    flex-direction: column;
    gap: 0.5rem;
}

.newsletter-form input {
    padding: 0.625rem 0.75rem;
    border: 1px solid #d1d5db;
    border-radius: 0.375rem;
    font-size: 1rem;
}

.newsletter-form button {
    padding: 0.625rem 1rem;
    background: #3b82f6;
    color: white;
    border: none;
    border-radius: 0.375rem;
    font-size: 1rem;
    cursor: pointer;
}

.newsletter-form button:disabled {
    opacity: 0.6;
    cursor: not-allowed;
}

.newsletter-error {
    color: #ef4444;
    font-size: 0.875rem;
    margin: 0.5rem 0 0;
}

.newsletter-success {
    color: #10b981;
    font-size: 1.125rem;
}

CLI

wp bs db list my-theme/newsletter --format=table
wp bs rpc call my-theme/newsletter stats
wp bs rpc call my-theme/newsletter unsubscribe --email=user@example.com
wp bs cron run my-theme/newsletter cleanup

Side by side

Todo AppNewsletter
Auth modeluserScoped: truePublic create, admin read
StorageSQLite (portable)MySQL table (production)
User filteringAutomaticNot needed
CSRFVia X-BS-TokenVia X-BS-Token
Custom RPCToggle, clear doneUnsubscribe, stats
CronDelete old completedRemove unsubscribed
ValidationLength constraintsEmail format + domain block
Realtime3s pollingNot needed
Optimistic UIbs.mutate() auto modeDirect bs.db().create()
Statewp_interactivity_state()wp_interactivity_state() + context

Same pattern, different access models. The building blocks are identical: db.php for the data model, rpc.php for business logic, cron.php for background tasks, index.php + script.inline.js for the UI, style.css for the look. Each block is a self-contained application.

On this page