Add custom fields to the WooCommerce block checkout

The old checkout field filters do nothing on the block checkout. The Additional Checkout Fields API does. Code, limits, and when to go further.

Published Updated 8 minute readWooCommerce
A form with a highlighted field and a check

The snippet that added a delivery date field to the WooCommerce checkout for six years stopped working the day a store switched to the block checkout, and it stopped without a message. The field disappeared. The woocommerce_checkout_fields filter it used still runs. It just runs for a checkout page the store no longer shows.

If your checkout is the block version, which every new WooCommerce store has had since 8.3, the old way of adding a field is not deprecated. It is irrelevant. The block checkout is a React application that reads its fields from a different place.

TLDR

On the block checkout, add fields with woocommerce_register_additional_checkout_field() on the woocommerce_init hook. Fields go in the contact, address or order section, support text, select and checkbox types, validate on the server, and land in the order, the confirmation email and the admin order screen. Anything the API does not support, such as a date picker or a field that changes the price, is a small plugin with a JavaScript block.

Why the old filters stopped working

The classic checkout is a PHP template. woocommerce_checkout_fields edits the array the template loops over, and every checkout customization since 2012 hooked into it. The block checkout is rendered in the browser from the Store API. The PHP array never reaches it.

The tell is in the page. Open the checkout page in the editor: if it holds a Checkout block, the filters are dead. If it holds the [woocommerce_checkout] shortcode, they work. Stores that upgraded from an old install often still have the shortcode. Stores set up after late 2023 have the block.

There is a second half to the breakage. Snippets that added a field usually had a companion that saved it, hooked to woocommerce_checkout_update_order_meta, and a third that showed it in the admin. All three are classic-only. A field that appears through some other route but never saves is worse than no field.

The supported way

WooCommerce shipped the Additional Checkout Fields API in 8.7, in early 2024. It is one function, called at the right time, and it handles rendering, validation, saving and display. Everything below was run on WooCommerce 11.1 with the block checkout before publishing.

A delivery instructions field and a PO number field for a B2B store:

add_action( 'woocommerce_init', function () {
    if ( ! function_exists( 'woocommerce_register_additional_checkout_field' ) ) {
        return;
    }

    woocommerce_register_additional_checkout_field( array(
        'id'       => 'acme/po-number',
        'label'    => 'Purchase order number',
        'location' => 'order',
        'type'     => 'text',
        'required' => false,
    ) );

    woocommerce_register_additional_checkout_field( array(
        'id'       => 'acme/delivery-instructions',
        'label'    => 'Delivery instructions',
        'location' => 'address',
        'type'     => 'text',
        'required' => false,
    ) );
} );

Three things to get right, because each one has caught a store we have worked with.

The hook is woocommerce_init, not init and not plugins_loaded. Register earlier and the function does not exist yet. The function_exists check keeps the site up if WooCommerce is deactivated.

The id has a namespace before the slash. It is stored under that key, so acme/po-number and a plugin’s otherplugin/po-number do not collide. The namespace is yours; pick one and keep it.

The location decides where the field appears and how often. contact sits with the email, once. address appears in both billing and shipping and is saved to each. order sits at the end, once, and is the right place for anything about the order rather than the person.

The block checkout showing an Additional order information section with a Purchase order number field

The purchase order number field registered above, rendered by the block checkout under "Additional order information" on a WooCommerce 11.1 test store.

Types are text, select and checkbox, and the source lists exactly those three. A select takes an options array. A checkbox that is required forces the customer to check it, which is how you do “I confirm I am over 18” without a plugin.

Reading the value back

The field saves to the order automatically. In a template, an email or a fulfillment integration, read it from the order object:

$po = $order->get_meta( '_wc_other/acme/po-number' );

The prefix depends on the location: _wc_other/ for order and contact fields, _wc_billing/ and _wc_shipping/ for address fields, which are saved once for each address. An older _wc_additional/ prefix was deprecated in 8.9 in favor of _wc_other/, so ignore snippets that use it. This is also what to give the person building the ERP sync, because the PO number is the field the accounting system will ask for on every invoice.

The value shows in the admin order screen under the section it was registered in, and in the customer’s order confirmation email, with no extra code. Placing an order through the Store API on a WooCommerce 11.1 test store with 123456 in the field created the order with exactly one new meta key, _wc_other/acme/po-number, holding that value.

The WooCommerce order edit screen showing Purchase order number 123456 in the order details

The same order in the admin. The field shows under the section it was registered in, with no extra code.

Validation and conditions

Server-side validation hooks into woocommerce_validate_additional_field, which receives the error object, the field id and the value. A PO number that has to be six digits:

add_action( 'woocommerce_validate_additional_field', function ( WP_Error $errors, $field_key, $field_value ) {
    if ( 'acme/po-number' === $field_key && '' !== $field_value && ! preg_match( '/^\d{6}$/', $field_value ) ) {
        $errors->add( 'po_number_format', 'The purchase order number is six digits.' );
    }
}, 10, 3 );

The error shows against the field at checkout. Because it runs on the server, it also runs for orders placed through the Store API from an app or a headless front end, which is the reason to validate here and not only in the browser. Posting a checkout to the Store API with 12ab in that field returns a 400 with the message above under additional_fields, which is what the block checkout displays.

Conditional display arrived in WooCommerce 9.9. The required and hidden options accept rules instead of a boolean, and the rules are evaluated against the cart, the customer and the other checkout fields.

The developer docs have a tutorial on conditional fields with the rule syntax. The document the rules read has three parts: cart.* with the total, the items and whether the order needs shipping, customer.* with the addresses and contact-location fields, and checkout.* with the payment method and order-location fields. A PO number that is required only when the cart total is over a threshold, or a company field hidden unless the customer checked a “buying for a business” checkbox, is a rule and no longer code.

The tutorial’s warning is worth repeating: the path to another field depends on that field’s location. Address-location values are not under additional_fields, and a rule that points at the wrong path fails silently. A condition on something outside the document, like a product category in the cart, still needs code: register the field always and validate it only when the condition applies, or write a small checkout block that reads the cart and shows the field itself.

When the API is not enough

Four cases from real quotes.

A date picker for delivery date. The API has no date type. The workaround is a text field with server validation for the format, which is what most stores accept. A real calendar with blocked days is a custom inner block for the checkout, written in JavaScript with @woocommerce/blocks-checkout, plus the same server validation.

A file upload for a prescription, a license or a design. No file type in the API and no plan for one. This is a custom block that uploads to the media library through the REST API before the order is placed, stores the attachment ID in an additional field, and shows the file on the admin order. About a week of work, and it is one of the checkout jobs we build most often.

A field that changes the price. Gift wrapping for $3, a rush fee, a deposit option. The field is easy; the fee is a woocommerce_cart_calculate_fees hook that reads the field’s value from the checkout data as the customer types. The block checkout sends field values to the server on change, so the fee updates live. The plumbing is fiddly and worth doing once, properly.

VAT number validation for EU B2B. A text field, validation against the VIES service on the server, and a tax exemption set on the customer when it passes. The field is ten lines. The VIES call and the tax logic are the work, and they belong with the store’s tax setup rather than in a checkout snippet.

Plugins, and when to skip them

Checkout Field Editor and the ThemeHigh version give a settings screen for the same API, which is right for a store owner who wants to add a text field without a developer. Their block checkout support arrived later than their classic support, so check the version. For a text or select field with no logic, buy the plugin and stop reading.

Skip the plugin when the field has logic: a condition, a price, a file, a date, or a downstream system that needs the value in a specific place. Each of those is a few days of code in a plugin of your own, and the code is the whole feature rather than a settings screen plus a snippet plus a workaround.

Moving a classic snippet across

The order of work for a store with old checkout snippets:

  1. List every snippet that touches checkout fields, in the theme, in a snippets plugin and in any custom plugin. Search for woocommerce_checkout_fields, woocommerce_checkout_update_order_meta and woocommerce_admin_order_data_after_billing_address.
  2. For each field, decide the location and type under the new API. Most map directly.
  3. Register the fields on a staging copy with the block checkout, place a test order, and check the order screen and the email.
  4. Update anything that reads the old meta key. The old key was whatever the snippet chose; the new one has the location prefix.
  5. Remove the old snippets. Leaving them in place does no harm today and confuses the next developer.

A store with three or four fields is a day. A store with a checkout that grew for six years is a feature sprint, and the snippet that added a delivery date in 2019 finally gets a calendar.

Questions

Does the Checkout Field Editor plugin work with the block checkout?

The newer versions register fields through the same Additional Checkout Fields API, so they show. The classic-only versions do not. Check the plugin's changelog for block checkout support before assuming.

Can I switch back to the classic checkout?

Yes. Replace the Checkout block with the [woocommerce_checkout] shortcode on the checkout page and the old filters work again. You lose the block checkout's express payments, address autocompletion and faster load. It is a stopgap, not a plan.

Have this problem on your store?Two sentences about it get you a written quote, or a plugin or app recommendation, within 48 hours.
Get a fixed quote