If you’ve ever needed repeatable fields in WordPress like multiple FAQs, social links, or custom attributes you’ve probably reached for ACF Pro. But what if you want a lightweight, dependency-free solution?

In this guide, we’ll show how to create a custom repeater field using vanilla JavaScript + WordPress meta boxes. Fully dynamic. No plugins. No ACF.

Use Case

We’re building a plugin that adds a repeater field to any post for example:

  • Team Members: Name, Role, and Image
  • FAQs: Question and Answer
  • Social Links: Icon + URL

Step 1: Register the Meta Box

add_action( 'add_meta_boxes', 'custom_repeater_add_meta_box' );
function custom_repeater_add_meta_box() {
    add_meta_box(
        'custom_repeater_box',
        __( 'FAQs', 'your-textdomain' ),
        'custom_repeater_render_callback',
        'post', // or any custom post type
        'normal',
        'default'
    );
}

Step 2: Render the Meta Box

function custom_repeater_render_callback( $post ) {
    wp_nonce_field( 'custom_repeater_nonce', 'custom_repeater_nonce_field' );
    $saved = get_post_meta( $post->ID, '_custom_faqs', true );

    ?>
    <div id="custom-repeater-wrapper">
        <?php if ( ! empty( $saved ) && is_array( $saved ) ) : ?>
            <?php foreach ( $saved as $item ) : ?>
                <div class="faq-item">
                    <input type="text" name="custom_faqs[][question]" value="<?php echo esc_attr( $item['question'] ); ?>" placeholder="Question" />
                    <textarea name="custom_faqs[][answer]" placeholder="Answer"><?php echo esc_textarea( $item['answer'] ); ?></textarea>
                    <button type="button" class="remove-faq">Remove</button>
                </div>
            <?php endforeach; ?>
        <?php endif; ?>
    </div>
    <button type="button" id="add-faq">+ Add FAQ</button>

    <style>
        .faq-item { margin-bottom: 15px; border: 1px solid #ccc; padding: 10px; background: #f9f9f9; }
        .faq-item input, .faq-item textarea { width: 100%; margin-bottom: 5px; }
    </style>
    <?php
}

Step 3: Add JavaScript to Handle “Add” and “Remove”

Enqueue the JS:



add_action( 'admin_enqueue_scripts', 'custom_repeater_admin_scripts' );
function custom_repeater_admin_scripts( $hook ) {
    if ( 'post.php' !== $hook && 'post-new.php' !== $hook ) return;
    
    wp_enqueue_script(
        'custom-repeater-js',
        plugin_dir_url( __FILE__ ) . 'repeater.js',
        array( 'jquery' ),
        '1.0',
        true
    );
}

Then in repeater.js:

jQuery(document).ready(function ($) {
    $('#add-faq').on('click', function () {
        const item = `
            <div class="faq-item">
                <input type="text" name="custom_faqs[][question]" placeholder="Question" />
                <textarea name="custom_faqs[][answer]" placeholder="Answer"></textarea>
                <button type="button" class="remove-faq">Remove</button>
            </div>`;
        $('#custom-repeater-wrapper').append(item);
    });

    $(document).on('click', '.remove-faq', function () {
        $(this).closest('.faq-item').remove();
    });
});

Step 4: Save the Repeater Data

add_action( 'save_post', 'custom_repeater_save_meta' );
function custom_repeater_save_meta( $post_id ) {
    if ( ! isset( $_POST['custom_repeater_nonce_field'] ) ||
         ! wp_verify_nonce( $_POST['custom_repeater_nonce_field'], 'custom_repeater_nonce' )
    ) {
        return;
    }

    if ( isset( $_POST['custom_faqs'] ) && is_array( $_POST['custom_faqs'] ) ) {
        $sanitized = [];

        foreach ( $_POST['custom_faqs'] as $faq ) {
            if ( empty( $faq['question'] ) && empty( $faq['answer'] ) ) continue;

            $sanitized[] = [
                'question' => sanitize_text_field( $faq['question'] ),
                'answer'   => sanitize_textarea_field( $faq['answer'] ),
            ];
        }

        update_post_meta( $post_id, '_custom_faqs', $sanitized );
    } else {
        delete_post_meta( $post_id, '_custom_faqs' );
    }
}

Bonus: Display FAQs on the Frontend

$faqs = get_post_meta( get_the_ID(), '_custom_faqs', true );

if ( $faqs && is_array( $faqs ) ) {
    echo '<div class="faq-section">';
    foreach ( $faqs as $faq ) {
        echo '<div class="faq">';
        echo '<h4>' . esc_html( $faq['question'] ) . '</h4>';
        echo '<p>' . esc_html( $faq['answer'] ) . '</p>';
        echo '</div>';
    }
    echo '</div>';
}