Create a Custom Meta Box with Code (No Plugin)
Need structured extra fields on post edit screens without installing a plugin? In this guide, you’ll add a secure, maintainable custom meta box using plain PHP. It supports multiple fields (text, textarea, select, checkbox), proper nonces, capability checks, autosave safety, and clean output on the front end.
What You’ll Build
A meta box titled “Article Extras” shown on Posts (and optionally Pages/CPTs) with fields:
- Subtitle (text)
- Summary (textarea)
- Difficulty (select: Beginner/Intermediate/Advanced)
- Featured (checkbox)
Complete Code (functions.php or a must-use plugin)
<?php
/**
* Custom Meta Box: Article Extras
* - No plugin required
* - Secure saving (nonce, caps, autosave guard)
* - Multiple field types
*/
// 0) (Optional) Register meta with schema (good for REST/Block Editor)
add_action( 'init', function () {
$post_types = array( 'post' ); // add 'page', 'your_cpt' if needed
$metas = array(
'wpt_subtitle' => array( 'type' => 'string', 'single' => true, 'sanitize_callback' => 'sanitize_text_field' ),
'wpt_summary' => array( 'type' => 'string', 'single' => true, 'sanitize_callback' => 'wp_kses_post' ), // allow limited HTML
'wpt_level' => array( 'type' => 'string', 'single' => true, 'sanitize_callback' => 'sanitize_text_field' ),
'wpt_featured' => array( 'type' => 'boolean', 'single' => true, 'sanitize_callback' => 'rest_sanitize_boolean' ),
);
foreach ( $post_types as $pt ) {
foreach ( $metas as $key => $args ) {
register_post_meta( $pt, $key, array_merge(
array(
'show_in_rest' => true, // available to block editor/REST
'auth_callback' => function() {
return current_user_can( 'edit_posts' );
},
),
$args
) );
}
}
} );
// 1) Add the meta box
add_action( 'add_meta_boxes', function () {
$screens = array( 'post' ); // add 'page', 'your_cpt' as needed
foreach ( $screens as $screen ) {
add_meta_box(
'wpt_article_extras',
__( 'Article Extras', 'your-textdomain' ),
'wpt_render_article_extras_box',
$screen,
'normal',
'default'
);
}
} );
// 2) Render fields HTML
function wpt_render_article_extras_box( $post ) {
// Retrieve existing values
$subtitle = get_post_meta( $post->ID, 'wpt_subtitle', true );
$summary = get_post_meta( $post->ID, 'wpt_summary', true );
$level = get_post_meta( $post->ID, 'wpt_level', true );
$featured = get_post_meta( $post->ID, 'wpt_featured', true );
// Nonce
wp_nonce_field( 'wpt_save_article_extras', 'wpt_article_extras_nonce' );
// Options for select
$levels = array(
'' => __( 'Select level…', 'your-textdomain' ),
'beginner' => __( 'Beginner', 'your-textdomain' ),
'intermediate' => __( 'Intermediate', 'your-textdomain' ),
'advanced' => __( 'Advanced', 'your-textdomain' ),
);
?>
<p>
<label for="wpt_subtitle"><strong><?php _e( 'Subtitle', 'your-textdomain' ); ?></strong></label><br>
<input type="text" id="wpt_subtitle" name="wpt_subtitle" class="widefat"
value="<?php echo esc_attr( $subtitle ); ?>" maxlength="160">
<small><?php _e( 'Optional short subtitle (max ~160 chars).', 'your-textdomain' ); ?></small>
</p>
<p>
<label for="wpt_summary"><strong><?php _e( 'Summary', 'your-textdomain' ); ?></strong></label><br>
<textarea id="wpt_summary" name="wpt_summary" class="widefat" rows="4"><?php
echo esc_textarea( $summary );
?></textarea>
<small><?php _e( 'Short description (basic HTML allowed).', 'your-textdomain' ); ?></small>
</p>
<p>
<label for="wpt_level"><strong><?php _e( 'Difficulty', 'your-textdomain' ); ?></strong></label><br>
<select id="wpt_level" name="wpt_level">
<?php foreach ( $levels as $val => $label ) : ?>
<option value="<?php echo esc_attr( $val ); ?>" <?php selected( $level, $val ); ?>>
<?php echo esc_html( $label ); ?>
</option>
<?php endforeach; ?>
</select>
</p>
<p>
<label>
<input type="checkbox" name="wpt_featured" value="1" <?php checked( (bool) $featured ); ?>>
<?php _e( 'Mark as Featured', 'your-textdomain' ); ?>
</label>
</p>
<?php
}
// 3) Save handler (secure)
add_action( 'save_post', function ( $post_id ) {
// A) Nonce and autosave checks
if ( ! isset( $_POST['wpt_article_extras_nonce'] ) ) return;
if ( ! wp_verify_nonce( $_POST['wpt_article_extras_nonce'], 'wpt_save_article_extras' ) ) return;
if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) return;
// B) Capability check
$post_type = get_post_type( $post_id );
$can_edit = current_user_can( 'edit_post', $post_id );
if ( ! $can_edit ) return;
// C) Sanitize inputs
$subtitle = isset( $_POST['wpt_subtitle'] ) ? sanitize_text_field( wp_unslash( $_POST['wpt_subtitle'] ) ) : '';
$summary = isset( $_POST['wpt_summary'] ) ? wp_kses_post( wp_unslash( $_POST['wpt_summary'] ) ) : '';
$level_in = isset( $_POST['wpt_level'] ) ? sanitize_text_field( wp_unslash( $_POST['wpt_level'] ) ) : '';
$featured = isset( $_POST['wpt_featured'] ) ? 1 : 0;
// D) Whitelist select options
$allowed_levels = array( 'beginner', 'intermediate', 'advanced' );
$level = in_array( $level_in, $allowed_levels, true ) ? $level_in : '';
// E) Update meta (use update_post_meta or registered keys)
update_post_meta( $post_id, 'wpt_subtitle', $subtitle );
update_post_meta( $post_id, 'wpt_summary', $summary );
update_post_meta( $post_id, 'wpt_level', $level );
update_post_meta( $post_id, 'wpt_featured', (int) $featured );
}, 10, 1 );
// 4) (Optional) Show a custom admin column for quick visibility
add_filter( 'manage_post_posts_columns', function ( $cols ) {
$cols['wpt_featured'] = __( 'Featured', 'your-textdomain' );
return $cols;
} );
add_action( 'manage_post_posts_custom_column', function ( $column, $post_id ) {
if ( 'wpt_featured' === $column ) {
echo get_post_meta( $post_id, 'wpt_featured', true ) ? '⭐' : '—';
}
}, 10, 2 );
Show the Meta on the Front End (theme template)
Use these snippets inside your theme templates (e.g., single.php, content-single.php) where appropriate.
<?php
$subtitle = get_post_meta( get_the_ID(), 'wpt_subtitle', true );
$summary = get_post_meta( get_the_ID(), 'wpt_summary', true );
$level = get_post_meta( get_the_ID(), 'wpt_level', true );
$featured = (bool) get_post_meta( get_the_ID(), 'wpt_featured', true );
?>
<?php if ( $subtitle ) : ?>
<h2 class="entry-subtitle"><?php echo esc_html( $subtitle ); ?></h2>
<?php endif; ?>
<?php if ( $summary ) : ?>
<div class="entry-summary meta-summary"><?php echo wp_kses_post( wpautop( $summary ) ); ?></div>
<?php endif; ?>
<?php if ( $level ) : ?>
<p class="entry-level"><strong>Level:</strong> <?php echo esc_html( ucfirst( $level ) ); ?></p>
<?php endif; ?>
<?php if ( $featured ) : ?>
<p class="entry-flag">★ Featured</p>
<?php endif; ?>
Extend to Other Post Types
To support Pages or a custom post type, add their slugs to the arrays in the code above (both the register_post_meta() and add_meta_box() sections), for example: array( 'post', 'page', 'portfolio' ).
Best Practices & Notes
- Security: Always use a nonce, capability checks, and sanitize inputs.
- REST/Block Editor: Prefer
register_post_meta()withshow_in_restand explicit types. - Performance: Fetch meta once per template and reuse variables to minimize queries.
- I18n: Wrap UI strings with translation functions (
__(),_e()).
Conclusion
With a few hooks and careful sanitization, you can add flexible, secure meta boxes without plugins. Registering your meta keys and rendering fields by hand keeps your admin UI lean, your data portable, and your theme logic clean.
🎨 Want to learn more? Visit our WordPress Customization Hub for tips and advanced techniques.