
How to Add Custom Fields to WordPress Posts Without a Plugin?
Sooner or later, a post needs more than a title and content. A recipe needs a cooking time, a review needs a rating, an event needs a date and a venue, and a product page needs a price that the theme can display in a consistent place. The usual answer is to install a fields plugin, and for complex projects that is often the right call. But WordPress has supported custom fields natively since its early days, and modern WordPress adds proper registration, REST API support, block editor integration, and the ability to bind fields directly to blocks. For a handful of fields, you do not need another plugin.
This article covers how WordPress stores custom fields, the built-in Custom Fields panel, registering fields properly with register_post_meta(), building a secure classic meta box, adding a modern block editor sidebar panel, displaying field values in classic and block themes, querying posts by field value, and the mistakes that cause fields to silently not save.
What Custom Fields Are in WordPress
In WordPress, a custom field is post meta: a key and value pair attached to a single post. Meta is stored in the wp_postmeta table, with one row per value:
| Column | Example value |
|---|---|
meta_id | 1842 |
post_id | 57 |
meta_key | wsm_subtitle |
meta_value | Fast weeknight pasta |
Core provides a small set of functions to work with it:
| Function | What it does |
|---|---|
get_post_meta( $id, $key, true ) | Reads one value (true returns a single value) |
update_post_meta( $id, $key, $value ) | Adds or updates a value |
add_post_meta( $id, $key, $value ) | Adds a value, allowing multiple per key |
delete_post_meta( $id, $key ) | Removes a value |
register_post_meta( $type, $key, $args ) | Declares the field's type, sanitization, and REST visibility |
Keys that start with an underscore, such as _wsm_price, are protected. They are hidden from the built-in Custom Fields panel, which is how plugins keep internal data out of sight.
Custom fields are different from custom post types and taxonomies. A post type is a kind of content, a taxonomy groups content, and a custom field stores a specific piece of data about one item. If you are also adding a new content type, see how to create a custom post type in WordPress.
Option 1: The Built-in Custom Fields Panel
WordPress still ships a simple Custom Fields panel. In the block editor it is hidden by default:
- Open any post in the editor.
- Click the three-dot Options menu in the top right, then Preferences.
- Under General, in the Advanced section, turn on Custom fields.
- The page reloads, and a Custom Fields panel appears below the content.
You can now type a name, such as wsm_subtitle, and a value, then click Add Custom Field. The value is saved with the post.
This panel is fine for occasional, developer-managed data. It is not great for editors: they must type the exact key, there is no validation, and every value is a plain string. For fields that editors use regularly, register them and give them a proper interface.
Option 2: Register Fields with register_post_meta
Registering a field tells WordPress what it is: its type, whether it holds one value or many, how to sanitize it, who may edit it, and whether it should be available in the REST API. The block editor talks to WordPress through the REST API, so show_in_rest is what makes a field usable in the editor and in block bindings.
Create a small plugin for your fields, so they survive theme changes:
<?php
/**
* Plugin Name: WSM Post Fields
* Description: Registers custom fields for posts.
* Version: 1.0.0
* Text Domain: wsm-post-fields
*/
// wp-content/plugins/wsm-post-fields/wsm-post-fields.php
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
function wsm_register_post_fields() {
register_post_meta(
'post',
'wsm_subtitle',
array(
'type' => 'string',
'description' => __( 'A short subtitle shown under the post title.', 'wsm-post-fields' ),
'single' => true,
'default' => '',
'show_in_rest' => true,
'sanitize_callback' => 'sanitize_text_field',
'auth_callback' => function () {
return current_user_can( 'edit_posts' );
},
)
);
register_post_meta(
'post',
'wsm_reading_level',
array(
'type' => 'string',
'single' => true,
'default' => 'beginner',
'show_in_rest' => array(
'schema' => array(
'type' => 'string',
'enum' => array( 'beginner', 'intermediate', 'advanced' ),
),
),
'sanitize_callback' => function ( $value ) {
$allowed = array( 'beginner', 'intermediate', 'advanced' );
return in_array( $value, $allowed, true ) ? $value : 'beginner';
},
'auth_callback' => function () {
return current_user_can( 'edit_posts' );
},
)
);
}
add_action( 'init', 'wsm_register_post_fields' );
What each argument does:
typeis the data type:string,boolean,integer,number,array, orobject. Arrays and objects need a schema inshow_in_rest.singleset totruestores one value per post, which is what you want for most fields.defaultis returned when the post has no value saved.sanitize_callbackcleans the value every time it is saved, through any route: the editor, the REST API, orupdate_post_meta().auth_callbackdecides who may edit the field. Without one, protected keys are not editable through the REST API at all.show_in_restexposes the field in REST responses undermeta. Passing an array with aschemalets you add validation like theenumabove.
One requirement is easy to miss: the post type must support custom-fields for meta to appear in the REST API. Posts and pages support it by default. For a custom post type, add 'custom-fields' to its supports array, or call add_post_type_support( 'your_type', 'custom-fields' ).
Prefix every key. A key like subtitle may collide with a theme or another plugin that stores something different under the same name.
Option 3: A Classic Meta Box
A meta box is a panel with your own form fields. Meta boxes work in both the classic editor and the block editor, where they appear below the content. They are a good choice if you need a quick interface without a JavaScript build step.
<?php
// wp-content/plugins/wsm-post-fields/includes/meta-box.php
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
function wsm_add_subtitle_meta_box() {
add_meta_box(
'wsm-subtitle',
__( 'Subtitle', 'wsm-post-fields' ),
'wsm_render_subtitle_meta_box',
'post',
'side',
'high'
);
}
add_action( 'add_meta_boxes', 'wsm_add_subtitle_meta_box' );
function wsm_render_subtitle_meta_box( $post ) {
$subtitle = get_post_meta( $post->ID, 'wsm_subtitle', true );
wp_nonce_field( 'wsm_save_subtitle', 'wsm_subtitle_nonce' );
?>
<label for="wsm-subtitle-field" class="screen-reader-text">
<?php esc_html_e( 'Subtitle', 'wsm-post-fields' ); ?>
</label>
<input
type="text"
id="wsm-subtitle-field"
name="wsm_subtitle"
value="<?php echo esc_attr( $subtitle ); ?>"
class="widefat"
/>
<?php
}
function wsm_save_subtitle_meta_box( $post_id ) {
// 1. Verify the nonce.
if ( ! isset( $_POST['wsm_subtitle_nonce'] ) || ! wp_verify_nonce( sanitize_key( $_POST['wsm_subtitle_nonce'] ), 'wsm_save_subtitle' ) ) {
return;
}
// 2. Skip autosaves and revisions.
if ( wp_is_post_autosave( $post_id ) || wp_is_post_revision( $post_id ) ) {
return;
}
// 3. Check the user can edit this post.
if ( ! current_user_can( 'edit_post', $post_id ) ) {
return;
}
// 4. Sanitize and save, or delete when empty.
if ( isset( $_POST['wsm_subtitle'] ) ) {
$subtitle = sanitize_text_field( wp_unslash( $_POST['wsm_subtitle'] ) );
if ( '' === $subtitle ) {
delete_post_meta( $post_id, 'wsm_subtitle' );
} else {
update_post_meta( $post_id, 'wsm_subtitle', $subtitle );
}
}
}
add_action( 'save_post_post', 'wsm_save_subtitle_meta_box' );
Load the file from the main plugin file with require_once __DIR__ . '/includes/meta-box.php';.
The save routine is where most meta box code goes wrong. Every check matters:
- Nonce verification proves the request came from your form, which stops cross-site request forgery.
- Autosave and revision checks stop the field from being cleared during an autosave, which does not include your form fields.
current_user_can( 'edit_post', $post_id )checks permission for this specific post, not just a general capability.wp_unslash()then sanitization removes the slashes WordPress adds to request data, then cleans the value.
The save_post_post hook is the post-type-specific version of save_post, so the callback only runs for posts. Use save_post_{post_type} for other types.
If a field is shown both in a meta box and in a block editor panel, choose one. Two interfaces editing the same key can overwrite each other on save.
Option 4: A Block Editor Sidebar Panel
For the most native experience, add a panel to the block editor's post sidebar. Because the field is registered with show_in_rest, the editor can read and write it through its data store, and the value saves with the post like the title does. No save_post handler is needed.
This requires a small JavaScript build with @wordpress/scripts. In your plugin folder:
# Terminal
cd wp-content/plugins/wsm-post-fields
npm init -y
npm install --save-dev @wordpress/scripts
Add "build": "wp-scripts build" and "start": "wp-scripts start" to the scripts section of package.json, then create the panel:
// wp-content/plugins/wsm-post-fields/src/index.js
import { registerPlugin } from "@wordpress/plugins";
import { PluginDocumentSettingPanel } from "@wordpress/editor";
import { TextControl, SelectControl } from "@wordpress/components";
import { useSelect } from "@wordpress/data";
import { useEntityProp } from "@wordpress/core-data";
import { __ } from "@wordpress/i18n";
function WsmPostFieldsPanel() {
const postType = useSelect(
(select) => select("core/editor").getCurrentPostType(),
[],
);
const [meta, setMeta] = useEntityProp("postType", postType, "meta");
if (postType !== "post" || !meta) {
return null;
}
return (
<PluginDocumentSettingPanel
name="wsm-post-fields"
title={__("Post details", "wsm-post-fields")}
>
<TextControl
__nextHasNoMarginBottom
__next40pxDefaultSize
label={__("Subtitle", "wsm-post-fields")}
value={meta.wsm_subtitle || ""}
onChange={(value) => setMeta({ ...meta, wsm_subtitle: value })}
/>
<SelectControl
__nextHasNoMarginBottom
__next40pxDefaultSize
label={__("Reading level", "wsm-post-fields")}
value={meta.wsm_reading_level}
options={[
{ label: __("Beginner", "wsm-post-fields"), value: "beginner" },
{
label: __("Intermediate", "wsm-post-fields"),
value: "intermediate",
},
{ label: __("Advanced", "wsm-post-fields"), value: "advanced" },
]}
onChange={(value) => setMeta({ ...meta, wsm_reading_level: value })}
/>
</PluginDocumentSettingPanel>
);
}
registerPlugin("wsm-post-fields", { render: WsmPostFieldsPanel });
Run npm run build. It creates build/index.js and build/index.asset.php, which lists the script's dependencies and a version hash. Enqueue it for the editor:
<?php
// wp-content/plugins/wsm-post-fields/wsm-post-fields.php (continued)
function wsm_post_fields_editor_assets() {
$asset_file = __DIR__ . '/build/index.asset.php';
if ( ! file_exists( $asset_file ) ) {
return;
}
$asset = include $asset_file;
wp_enqueue_script(
'wsm-post-fields-editor',
plugins_url( 'build/index.js', __FILE__ ),
$asset['dependencies'],
$asset['version'],
true
);
}
add_action( 'enqueue_block_editor_assets', 'wsm_post_fields_editor_assets' );
Open a post and look in the Post tab of the sidebar. A Post details panel shows both fields, and changes save when you click Save or Update.
PluginDocumentSettingPanel is imported from @wordpress/editor. Before WordPress 6.6 it lived in @wordpress/edit-post, which you will still see in older tutorials.
Displaying Custom Field Values
In a Classic Theme
Read the value with get_post_meta() and always escape it on output:
<?php
// wp-content/themes/your-child-theme/single.php (inside the loop)
$subtitle = get_post_meta( get_the_ID(), 'wsm_subtitle', true );
if ( $subtitle ) : ?>
<p class="entry-subtitle"><?php echo esc_html( $subtitle ); ?></p>
<?php endif; ?>
Or add it automatically without editing templates, using a filter:
<?php
// wp-content/plugins/wsm-post-fields/wsm-post-fields.php (continued)
function wsm_prepend_subtitle( $content ) {
if ( ! is_singular( 'post' ) || ! in_the_loop() || ! is_main_query() ) {
return $content;
}
$subtitle = get_post_meta( get_the_ID(), 'wsm_subtitle', true );
if ( ! $subtitle ) {
return $content;
}
return '<p class="entry-subtitle">' . esc_html( $subtitle ) . '</p>' . $content;
}
add_filter( 'the_content', 'wsm_prepend_subtitle' );
In a Block Theme with Block Bindings
Block themes do not have PHP templates to edit, but the Block Bindings API connects a core block's content directly to a custom field. A Paragraph block bound to wsm_subtitle shows the field value for whichever post is being displayed. Add this to a template in the Site Editor's code editor, or to a template file in your theme:
<!-- wp:paragraph {"metadata":{"bindings":{"content":{"source":"core/post-meta","args":{"key":"wsm_subtitle"}}}}} -->
<p></p>
<!-- /wp:paragraph -->
The field must be registered with show_in_rest set to true, which the registration above does. Bindings work with the Paragraph, Heading, Image, and Button blocks, and recent WordPress versions let editors change bound values directly in the editor. The full feature is covered in how to use the WordPress Block Bindings API.
Querying Posts by Custom Field
Custom fields are also useful for filtering. WP_Query supports meta_query for conditions on meta values:
<?php
// wp-content/plugins/wsm-post-fields/wsm-post-fields.php (example usage)
$advanced_posts = new WP_Query(
array(
'post_type' => 'post',
'posts_per_page' => 10,
'meta_query' => array(
array(
'key' => 'wsm_reading_level',
'value' => 'advanced',
),
),
)
);
Meta queries are convenient but not fast on large sites, because meta_value is not indexed. If you filter on a field constantly, especially across thousands of posts, a custom taxonomy is usually the better data model. Reading level, for example, works well as a taxonomy because it is a small set of shared values. A subtitle does not, because every post has a unique one.
Choosing the Right Approach
| Approach | Best for | Editor experience | Code needed |
|---|---|---|---|
| Built-in Custom Fields panel | Occasional values set by developers | Basic | None |
register_post_meta only | Values set via code, REST, or block bindings | None by itself | A few lines of PHP |
| Classic meta box | Quick admin UI, classic editor sites | Good | PHP only |
| Block editor sidebar panel | Modern editorial workflows | Best | PHP and a JS build |
For most new sites on the block editor, the combination of register_post_meta() plus a sidebar panel plus block bindings gives the cleanest result with no third-party dependency.
Common Problems and Fixes
- The field does not appear in the editor or REST API.
show_in_restis not set, the field is registered too late or on the wrong post type, or the post type does not supportcustom-fields. - The meta box value is not saved. The nonce name in
wp_nonce_field()does not match the one checked on save, or the save callback is hooked to the wrongsave_post_{post_type}hook. - Values disappear after autosave. The save routine does not skip autosaves, so it saves an empty value when the form fields are missing. Add the autosave check.
- "Sorry, you are not allowed to edit the meta field." The key is protected (starts with an underscore) and has no
auth_callback, or the callback returns false for the current user. - The Custom Fields panel does not show my key. Protected keys starting with an underscore are hidden by design. Use a key without the underscore if editors need to see it there, or give the field its own interface.
- Block binding shows nothing. The key is misspelled, the field is not registered with
show_in_rest, or the template is not displaying a post that has a value.
Custom Fields FAQ
In the wp_postmeta database table. Each row stores a post ID, a meta key, and a meta value. Values that are arrays are serialized automatically when saved and unserialized when read.
Not for simple fields. WordPress core can register fields, show them in the editor, save them securely, and display them in themes. A fields plugin becomes worthwhile when you need many field types, repeaters, complex layouts, or a no-code interface for non-developers.
The block editor reads and writes meta through the REST API, so the field must be registered with show_in_rest set to true, and the post type must support custom-fields. The built-in Custom Fields panel must also be enabled in the editor preferences if you want to use it.
Keys that begin with an underscore are protected. They are hidden from the built-in Custom Fields panel and need an auth_callback when registered to be editable through the REST API.
Yes, with the Block Bindings API. A Paragraph, Heading, Image, or Button block can be bound to a registered field using the core post meta source, and the block displays the field value for the current post.
Conclusion
WordPress has everything you need for custom fields built in. Post meta stores the data, register_post_meta() defines the type, sanitization, permissions, and REST visibility, and you choose the interface: the basic built-in panel, a classic meta box with proper nonce and capability checks, or a native block editor panel built with useEntityProp. Displaying the data is just as flexible, with get_post_meta() in classic themes and block bindings in block themes.
Start by registering every field, even if you only need the built-in panel today. Registration is what makes fields secure, consistent, and available everywhere WordPress needs them, and it makes adding a better interface later a small step instead of a rewrite.
Here are some useful references for going deeper on custom fields:
- WordPress Developer Resources: register_post_meta() — function reference with every registration argument.
- WordPress Developer Resources: Custom Meta Boxes — the Plugin Handbook guide to meta boxes and saving data.
- Block Editor Handbook: PluginDocumentSettingPanel — adding panels to the post sidebar.
- Block Editor Handbook: Block Bindings API — connecting block content to custom fields.
- WordPress Developer Resources: WP_Query custom field parameters — querying posts with meta_query.


