Build a WordPress Settings Page with the Settings API

A maintainable WordPress settings page has four parts: a registered option, a validation callback, a page restricted by capability, and fields rendered through the Settings API. WordPress then handles the form action, nonce, and error display consistently with the rest of the admin. This tutorial builds a small reading-time plugin setting without using a framework or saving raw $_POST data. The finished example is intentionally compact, but its structure works for larger plugins because registration, rendering, validation, and defaults remain separate.
Define the setting before building the screen
Assume the plugin stores one option named adz_reader_options. Using an array leaves room for related preferences without adding a separate option for every field. Register it on admin_init, provide a schema, and pass every update through a sanitizer.
add_action( 'admin_init', 'adz_reader_register_settings' );
function adz_reader_register_settings() {
register_setting( 'adz_reader', 'adz_reader_options', array(
'type' => 'array',
'default' => array( 'words_per_minute' => 225 ),
'sanitize_callback' => 'adz_reader_sanitize_options',
) );
}
The first argument is the settings group used later by settings_fields(). Keep the group and option names stable: they connect registration to submission and become part of your plugin’s data contract.
Validate the value for its actual job
Sanitization is not permission to coerce anything into an acceptable value. For reading speed, reject values outside a reasonable operational range and preserve the previous value. Add a settings error so the user knows why the save did not take effect.
function adz_reader_sanitize_options( $input ) {
$current = get_option( 'adz_reader_options', array( 'words_per_minute' => 225 ) );
$value = isset( $input['words_per_minute'] )
? absint( $input['words_per_minute'] )
: 0;
if ( $value < 100 || $value > 600 ) {
add_settings_error(
'adz_reader_options',
'invalid_words_per_minute',
__( 'Enter a reading speed between 100 and 600.', 'adz-reader' )
);
return $current;
}
return array( 'words_per_minute' => $value );
}
Validate against the domain, not merely the PHP type. absint() makes an integer non-negative; it does not decide whether that integer makes sense.
Add the page with the right capability
Create the page on admin_menu. The manage_options capability is appropriate for a site-wide administrative setting. A content preference might use a different capability, but never substitute a role name such as “administrator.” Capabilities are the stable WordPress authorization layer.
add_action( 'admin_menu', function () {
add_options_page(
__( 'Reading Time', 'adz-reader' ),
__( 'Reading Time', 'adz-reader' ),
'manage_options',
'adz-reader',
'adz_reader_render_page'
);
} );
WordPress hides the menu from unauthorized users, but the render callback should still check the capability. Defense in depth protects the page if it is called in an unexpected context.
Register the section and field
Fields belong to a section, and sections belong to a page slug. Register both during the same admin_init callback used for the option.
add_settings_section(
'adz_reader_main',
__( 'Reading-time calculation', 'adz-reader' ),
'__return_false',
'adz-reader'
);
add_settings_field(
'adz_reader_words_per_minute',
__( 'Words per minute', 'adz-reader' ),
'adz_reader_render_speed_field',
'adz-reader',
'adz_reader_main'
);
The field callback should render only its control and description. Retrieve the complete option, merge defaults, and escape the value at output time.
function adz_reader_render_speed_field() {
$options = wp_parse_args(
get_option( 'adz_reader_options', array() ),
array( 'words_per_minute' => 225 )
);
?>
<input type="number" min="100" max="600"
name="adz_reader_options[words_per_minute]"
value="<?php echo esc_attr( $options['words_per_minute'] ); ?>">
<p class="description"><?php esc_html_e( 'Used to estimate article reading time.', 'adz-reader' ); ?></p>
<?php
}
Render the standard WordPress form
The Settings API submits to options.php. That core endpoint checks the generated nonce and the option’s allowed group before invoking your sanitizer.
function adz_reader_render_page() {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'You cannot manage these settings.', 'adz-reader' ) );
}
?>
<div class="wrap">
<h1><?php esc_html_e( 'Reading Time', 'adz-reader' ); ?></h1>
<form action="options.php" method="post">
<?php
settings_fields( 'adz_reader' );
do_settings_sections( 'adz-reader' );
submit_button();
?>
</form>
</div>
<?php
}
Verify the page as a user and a developer
- Save 250 and confirm the option contains an integer.
- Submit 99 and 601 and confirm the previous value survives with a useful error.
- Visit the URL as a user without
manage_optionsand confirm access is denied. - Delete the option and confirm the default renders without warnings.
- Enable debugging and check the PHP log during every path.
Also test translations and keyboard navigation. A settings page is finished when failure is understandable and unauthorized access is blocked, not merely when the happy path saves.
For a plugin with several pages, keep option reads behind a small accessor such as adz_reader_get_option(). That function can merge defaults, cast the requested value, and provide one future migration point. It also prevents templates and callbacks from repeating subtly different fallback logic. Avoid caching the option in a static variable unless the code accounts for updates during the same request; settings tests and import routines often write and read immediately. A dependable accessor is more valuable than saving one inexpensive option lookup.
Use the API as a boundary
The Settings API does not remove the need for judgment, but it gives the page a dependable boundary: WordPress owns submission mechanics while the plugin owns authorization, domain validation, defaults, and escaped output. Keep those responsibilities visible and a small settings screen will remain easy to extend without becoming a collection of special cases. The official Settings API documentation is the best reference when adding more field types or sections.
Photo by Tima Miroshnichenko on Pexels.
Written by
Adrian Saycon
A developer with a passion for emerging technologies, Adrian Saycon focuses on transforming the latest tech trends into great, functional products.


