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.phpBlock definition
{
"$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.
<?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.
<?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.
<?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.
<?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"
>×</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-contextfor ephemeral UI state (input text)data-wp-each--todogives 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.
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: passstate,key,action, andoptimisticdata. The UI updates immediately, the server call happens in the background, and the state rolls back on failure.bs.mutate()manual mode forclearDone:before()takes a snapshot and applies the change,onError()restores from the snapshot.- Generator functions (
*addTodo) withyieldfor async operations in the Interactivity API. bs.db()for direct CRUD,bs.fn()for custom server functions.
Styles
.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=1Newsletter 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.phpBlock definition
{
"$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.
<?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:
<?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
<?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
<?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
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
.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 cleanupSide by side
| Todo App | Newsletter | |
|---|---|---|
| Auth model | userScoped: true | Public create, admin read |
| Storage | SQLite (portable) | MySQL table (production) |
| User filtering | Automatic | Not needed |
| CSRF | Via X-BS-Token | Via X-BS-Token |
| Custom RPC | Toggle, clear done | Unsubscribe, stats |
| Cron | Delete old completed | Remove unsubscribed |
| Validation | Length constraints | Email format + domain block |
| Realtime | 3s polling | Not needed |
| Optimistic UI | bs.mutate() auto mode | Direct bs.db().create() |
| State | wp_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.