When working with WordPress, you may often need to add custom sections or fields to your page editor — for example, an extra text field, color picker, or image uploader that stores page-specific data.
In this tutorial, you’ll learn how to create a custom section (meta box) inside the WordPress page edit screen, save the data to the database, and display it on the frontend.
Whether you want to add an “Extra Info”, “Banner Settings”, or “Custom CTA Text” section — this method will give you complete flexibility.
If you’ve ever needed a client to fill in a banner headline, pick a highlight color, or upload a hero image without touching a line of code, you’ve bumped into the limits of WordPress’s default “Custom Fields” panel. It works, but it’s clunky — raw key/value inputs with no labels, no validation, and no structure.
The professional fix is a custom meta box: a dedicated panel on the page-edit screen that looks like it belongs there, stores its data safely, and renders it on the front end exactly where you want. This guide walks through building one from scratch — text fields, a color picker, a media uploader, and a dropdown — with the security details most tutorials skip.
Why Meta Boxes Beat Plain Custom Fields
WordPress ships with a generic “Custom Fields” metabox, but relying on it directly has real downsides:
- No UI control — it’s a flat list of text inputs, unlabeled and easy to break.
- No validation — anyone can type anything into any field.
- No grouping — five related settings show up as five unrelated rows.
- No field types — no color pickers, media buttons, or dropdowns without extra plugins.
A custom meta box gives you a proper mini-form, backed by the same wp_postmeta table, but with full control over layout, sanitization, and field types. It’s also the same underlying mechanism plugins like Advanced Custom Fields use — so understanding it demystifies a lot of “magic” plugin behavior too.
Step 1: Add a Custom Meta Box to Page Editor
Add the following code to your theme’s functions.php file or a custom plugin file.
function my_page_custom_section() {
add_meta_box(
'page_extra_section', // Unique ID
'Extra Page Section', // Box title
'my_page_custom_section_html', // Callback function
'page', // Post type: 'page'
'normal', // Context (normal, side, advanced)
'default' // Priority
);
}
add_action('add_meta_boxes', 'my_page_custom_section');
function my_page_custom_section_html($post) {
wp_nonce_field('my_page_custom_section_nonce', 'my_page_custom_section_nonce_field');
$custom_text = get_post_meta($post->ID, '_custom_page_text', true);
$custom_color = get_post_meta($post->ID, '_custom_page_color', true);
echo '<p><label for="custom_page_text"><strong>Custom Text:</strong></label></p>';
echo '<input type="text" id="custom_page_text" name="custom_page_text" value="' . esc_attr($custom_text) . '" style="width:100%; max-width:600px;">';
echo '<p><label for="custom_page_color"><strong>Background Color:</strong></label></p>';
echo '<input type="color" id="custom_page_color" name="custom_page_color" value="' . esc_attr($custom_color) . '">';
}
This code registers a new meta box called “Extra Page Section” that will appear on the Edit Page screen inside your WordPress dashboard.
Step 2: Save the Custom Field Data
Next, you need to save the data entered into those fields when a page is updated.
Add this below the previous code:
function save_my_page_custom_section($post_id) {
if (!isset($_POST['my_page_custom_section_nonce_field']) ||
!wp_verify_nonce($_POST['my_page_custom_section_nonce_field'], 'my_page_custom_section_nonce')) {
return;
}
if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
return;
}
if (!current_user_can('edit_page', $post_id)) {
return;
}
if (isset($_POST['custom_page_text'])) {
update_post_meta($post_id, '_custom_page_text', sanitize_text_field($_POST['custom_page_text']));
}
if (isset($_POST['custom_page_color'])) {
update_post_meta($post_id, '_custom_page_color', sanitize_hex_color($_POST['custom_page_color']));
}
}
add_action('save_post_page', 'save_my_page_custom_section');
This ensures your data is safely stored in the wp_postmeta table whenever a page is saved or updated.
Step 3: Display Custom Section on Frontend
Finally, let’s show the custom text and background color on the actual page.
Open your page.php (or custom page template) and add this code where you want the section to appear:
<?php
$custom_text = get_post_meta(get_the_ID(), '_custom_page_text', true);
$custom_color = get_post_meta(get_the_ID(), '_custom_page_color', true);
if (!empty($custom_text)) {
echo '<div style="background:' . esc_attr($custom_color ?: '#f5f5f5') . '; padding:20px; margin:20px 0;">';
echo '<h3>' . esc_html($custom_text) . '</h3>';
echo '</div>';
}
?>
Now, whenever you edit a page and fill out your Extra Page Section, the data will appear on that page’s frontend automatically.
Step 4: Enhance with More Fields (Optional)
You can easily expand this meta box with:
- Textareas (for longer content)
- Select dropdowns
- Date pickers
- Image uploaders (using wp.media)
Example:
echo '<p><label>Highlight Text:</label><br>'; echo '<textarea name="highlight_text" rows="4" style="width:100%;">' . esc_textarea(get_post_meta($post->ID, '_highlight_text', true)) . '</textarea></p>';
And then save it using:
update_post_meta($post_id, '_highlight_text', sanitize_textarea_field($_POST['highlight_text']));
Why Use Meta Boxes Instead of Custom Fields?
While WordPress has a “Custom Fields” option, meta boxes give you:
- A cleaner UI
- Multiple fields grouped under one heading
- Better control and validation
- Seamless integration with your post type or page editor
This is the professional developer’s approach when customizing the WordPress admin area.
Adding a custom section to your WordPress Page editor gives you more flexibility and structure when building dynamic, data-driven sites. Whether you’re adding banner text, SEO data, course info, or call-to-action content — meta boxes make it easy to create powerful, user-friendly admin interfaces.
With just a few lines of PHP, you can take full control of how your pages store and display information.
Method 2:
Step 1: Register the Meta Box
Meta boxes hook into add_meta_boxes. Drop this into your theme’s functions.php, or better, a small site-specific plugin (so it survives a theme switch):
add_action( 'add_meta_boxes', 'sitename_register_page_section_box' );
function sitename_register_page_section_box() {
add_meta_box(
'sitename_page_section', // unique ID
'Page Highlight Section', // box title shown in the admin
'sitename_render_section_box', // render callback
'page', // screen: only on Pages
'normal', // context: normal, side, advanced
'high' // priority
);
}
Using a unique prefix (sitename_) on every function and meta key avoids collisions with plugins or other themes — a habit worth building early.
Step 2: Build the Field Markup
This is where a custom box earns its keep. Instead of one plain text input, give editors a real toolkit: a headline field, a textarea, a color swatch, a dropdown, and an image button.
function sitename_render_section_box( $post ) {
wp_nonce_field( 'sitename_save_section', 'sitename_section_nonce' );
$headline = get_post_meta( $post->ID, '_sitename_headline', true );
$body = get_post_meta( $post->ID, '_sitename_body', true );
$color = get_post_meta( $post->ID, '_sitename_color', true ) ?: '#f5f5f5';
$layout = get_post_meta( $post->ID, '_sitename_layout', true ) ?: 'boxed';
$image_id = get_post_meta( $post->ID, '_sitename_image_id', true );
?>
<p>
<label for="sitename_headline"><strong>Headline</strong></label><br>
<input type="text" id="sitename_headline" name="sitename_headline"
value="<?php echo esc_attr( $headline ); ?>" style="width:100%;">
</p>
<p>
<label for="sitename_body"><strong>Body Text</strong></label><br>
<textarea id="sitename_body" name="sitename_body" rows="4"
style="width:100%;"><?php echo esc_textarea( $body ); ?></textarea>
</p>
<p>
<label for="sitename_color"><strong>Background Color</strong></label><br>
<input type="color" id="sitename_color" name="sitename_color"
value="<?php echo esc_attr( $color ); ?>">
</p>
<p>
<label for="sitename_layout"><strong>Layout Style</strong></label><br>
<select id="sitename_layout" name="sitename_layout">
<option value="boxed" <?php selected( $layout, 'boxed' ); ?>>Boxed</option>
<option value="full-width" <?php selected( $layout, 'full-width' ); ?>>Full Width</option>
<option value="split" <?php selected( $layout, 'split' ); ?>>Split with Image</option>
</select>
</p>
<p>
<label><strong>Section Image</strong></label><br>
<input type="hidden" id="sitename_image_id" name="sitename_image_id" value="<?php echo esc_attr( $image_id ); ?>">
<button type="button" class="button" id="sitename_upload_btn">Choose Image</button>
<button type="button" class="button" id="sitename_remove_btn" <?php echo $image_id ? '' : 'style="display:none;"'; ?>>Remove</button>
<div id="sitename_image_preview" style="margin-top:10px;">
<?php if ( $image_id ) { echo wp_get_attachment_image( $image_id, 'medium' ); } ?>
</div>
</p>
<?php
}
Step 3: Wire Up the Media Uploader
The image button needs a small script that calls the built-in wp.media frame. Enqueue it only on the page-edit screen to avoid loading it everywhere:
add_action( 'admin_enqueue_scripts', 'sitename_enqueue_media_script' );
function sitename_enqueue_media_script( $hook ) {
if ( 'post.php' !== $hook && 'post-new.php' !== $hook ) {
return;
}
if ( 'page' !== get_current_screen()->post_type ) {
return;
}
wp_enqueue_media();
wp_enqueue_script(
'sitename-media-uploader',
get_stylesheet_directory_uri() . '/js/sitename-media-uploader.js',
array( 'jquery' ),
'1.0',
true
);
}
And the JS file itself:
jQuery(function ($) {
let frame;
$('#sitename_upload_btn').on('click', function (e) {
e.preventDefault();
if (frame) { frame.open(); return; }
frame = wp.media({
title: 'Select Section Image',
button: { text: 'Use this image' },
multiple: false
});
frame.on('select', function () {
const attachment = frame.state().get('selection').first().toJSON();
$('#sitename_image_id').val(attachment.id);
$('#sitename_image_preview').html('<img src="' + attachment.url + '" style="max-width:200px;">');
$('#sitename_remove_btn').show();
});
frame.open();
});
$('#sitename_remove_btn').on('click', function (e) {
e.preventDefault();
$('#sitename_image_id').val('');
$('#sitename_image_preview').html('');
$(this).hide();
});
});
Step 4: Save the Data Securely
This is the step where most quick tutorials cut corners. A proper save handler checks four things, in order: the nonce, autosave status, the post type, and the user’s capability — before touching the database.
add_action( 'save_post_page', 'sitename_save_section_data' );
function sitename_save_section_data( $post_id ) {
if ( ! isset( $_POST['sitename_section_nonce'] ) ||
! wp_verify_nonce( $_POST['sitename_section_nonce'], 'sitename_save_section' ) ) {
return;
}
if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
return;
}
if ( ! current_user_can( 'edit_page', $post_id ) ) {
return;
}
$fields = array(
'_sitename_headline' => isset( $_POST['sitename_headline'] ) ? sanitize_text_field( $_POST['sitename_headline'] ) : '',
'_sitename_body' => isset( $_POST['sitename_body'] ) ? sanitize_textarea_field( $_POST['sitename_body'] ) : '',
'_sitename_color' => isset( $_POST['sitename_color'] ) ? sanitize_hex_color( $_POST['sitename_color'] ) : '',
'_sitename_layout' => isset( $_POST['sitename_layout'] ) ? sanitize_key( $_POST['sitename_layout'] ) : 'boxed',
'_sitename_image_id' => isset( $_POST['sitename_image_id'] ) ? absint( $_POST['sitename_image_id'] ) : 0,
);
foreach ( $fields as $key => $value ) {
update_post_meta( $post_id, $key, $value );
}
}
Notice each field gets a matching sanitizer — sanitize_hex_color() for the color picker, absint() for the attachment ID, sanitize_key() for the dropdown. Using sanitize_text_field() for everything is the most common shortcut that quietly lets bad data through.
Step 5: Render It on the Front End
Pull the values back out and echo them wherever your template needs them — inside page.php, a custom page template, or a template part:
<?php
$headline = get_post_meta( get_the_ID(), '_sitename_headline', true );
$body = get_post_meta( get_the_ID(), '_sitename_body', true );
$color = get_post_meta( get_the_ID(), '_sitename_color', true ) ?: '#f5f5f5';
$layout = get_post_meta( get_the_ID(), '_sitename_layout', true );
$image_id = get_post_meta( get_the_ID(), '_sitename_image_id', true );
if ( $headline ) :
?>
<section class="page-highlight layout-<?php echo esc_attr( $layout ); ?>"
style="background-color: <?php echo esc_attr( $color ); ?>;">
<?php if ( $image_id && 'split' === $layout ) : ?>
<div class="highlight-image"><?php echo wp_get_attachment_image( $image_id, 'large' ); ?></div>
<?php endif; ?>
<div class="highlight-text">
<h3><?php echo esc_html( $headline ); ?></h3>
<?php if ( $body ) : ?>
<p><?php echo esc_html( $body ); ?></p>
<?php endif; ?>
</div>
</section>
<?php endif; ?>
Always run output through an escaping function (esc_html, esc_attr) even though you sanitized on save — this is defense in depth, not redundancy.
Making It Work with the Block Editor (Gutenberg)
Classic meta boxes still render in the block editor by default, but they show up below the block canvas rather than in the sidebar, which feels dated. Two options if that bothers you:
- Keep the classic meta box — perfectly fine for internal/admin-only fields that don’t need a polished editorial feel.
- Convert it to a sidebar plugin using registerPlugin and PluginDocumentSettingPanel from @wordpress/edit-post, which renders your fields natively in the block editor sidebar. This requires exposing the meta fields via register_post_meta() with show_in_rest => true so the block editor’s REST-backed data layer can read and write them.
If you’re building something client-facing that needs to feel modern, the REST-registered approach is worth the extra setup. For internal tooling, the classic meta box above is simpler and just as reliable.
Common Mistakes to Avoid
- Skipping the nonce check — leaves the field open to being changed via a forged request from another site.
- Forgetting current_user_can() — without it, any user who can reach the save action (including in some multisite/contributor edge cases) can write to fields they shouldn’t touch.
- Mismatched sanitizers — passing a hex color through sanitize_text_field() will “work” until someone pastes in something unexpected.
- Not namespacing meta keys — _headline will collide with another plugin’s _headline eventually; always prefix.
- Forgetting escaping on output — sanitizing on save protects your database, not your HTML output.
Where to Go From Here
Once this pattern feels natural, the same building blocks — add_meta_box(), a nonce-checked save handler, and get_post_meta() on the front end — extend to almost any admin customization: post-type-specific settings, taxonomy term meta, or even user profile fields via show_user_profile. The meta box is the smallest unit of “custom admin UI” in WordPress, and it’s the one every other structured-content plugin builds on top of.