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>';
}