Why Add an ACF Frontend Form in WordPress
When you’re building a WordPress site for clients or contributors, you often hit a common problem: the WordPress backend isn’t friendly for non-technical users.
Most people don’t want to see menus, settings, or the block editor. They want a simple way to add or edit posts.
That’s where ACF frontend forms come in. Instead of forcing users into the backend, you can drop a clean, functional form right into any page or post. In this tutorial, I’ll show you exactly how to add an ACF frontend form in WordPress — step by step, with the code you need.
Accompanying Video
Why I Stopped Using Frontend Form Plugins
When I first tried to solve this problem, I used one of the big frontend form plugins. It worked… until it didn’t.
Often, an update would break something — forms wouldn’t submit, styling would be broken, or even worse, a white screen would appear. I got tired of rolling back versions and waiting for fixes.
So I switched to using ACF’s built-in capabilities with a couple of shortcodes. It’s lighter, more reliable, and plays nicely with any page builder.
Step 1: Set up Your ACF Field Group
First, create a field group in ACF with the fields you want to include in your form (such as title, description, images, etc.).

The ‘Event’ field group for our ACF Frontend Form in WordPress

The fields inside the ‘Event’ field group.
Step 2: Add the PHP Code
These shortcodes don’t exist in WordPress by default. You’ll need to add the following PHP code to your theme’s functions.php file. You could also use the WPCode plugin to add this code as a PHP code snippet. The companion video above will show you how.
You’ll need to adapt this code to your website and post types. Look for inline comments (they start with double slashes //) that look like this:
$dashboard_page = get_page_by_path( 'dashboard' ); //change this to the slug of the page you'd like the visitor to be sent to once they save the form.In the code above, you’d change ‘dashboard’ to the slug of the page you’d like the visitor to be sent to once they save the form.
You’ll need to know the following:
- The slugs of every page that has an ACF frontend form
- Your field group’s Key (group_68a37d1a89071 in the image above)
- The slug of the page you’d like the visitor to be sent to once they save the form. We use ‘dashboard’ in our example code below.
- The slug of the page you’d like the visitor to be sent to if they fail validation. We also use ‘dashboard’ in the code below, but you may wish to send the visitor to an error page.
/**
* Pages where frontend ACF forms are used.
*
* @return array
*/
function invouq_acf_form_pages(): array {
return array(
'add-event-elementor', // put the slugs of every page that has an ACF frontend form into this array
'edit-event-elementor',
'edit-event-gutenberg',
);
}
/**
* Check if current page requires ACF frontend form support.
*
* @return bool
*/
function invouq_is_acf_form_page(): bool {
return is_page( invouq_acf_form_pages() );
}
/**
* Conditionally outputs ACF form setup scripts in the <head> section
* for specific frontend pages that render ACF forms.
*
* acf_form_head() must run before any HTML is output, so it is hooked into `wp_head` with priority 1.
*
* @return void
*/
function invouq_insert_acf_form_head(): void {
if ( invouq_is_acf_form_page() ) {
acf_form_head();
}
}
add_action( 'wp_head', 'invouq_insert_acf_form_head', 1 );
/**
* Enqueue necessary ACF scripts and media uploader on edit & add event pages.
*
* Ensures that the ACF JavaScript and media uploader assets are loaded when editing on the front end.
*
* @return void
*/
function invouq_enqueue_acf_scripts(): void {
if ( invouq_is_acf_form_page() ) {
acf_enqueue_scripts();
acf_enqueue_uploader();
}
}
add_action( 'wp_enqueue_scripts', 'invouq_enqueue_acf_scripts' );
/**
* Render add post form using [add-post-frontend] shortcode
*
* @link https://www.advancedcustomfields.com/resources/acf_form/
*
* @return string The form html
*/
function invouq_render_add_post(): string {
if ( ! current_user_can( 'edit_posts' ) ) {
return "<strong>You do not have permission to add a new event.</strong>";
}
$dashboard_page = get_page_by_path( 'dashboard' ); //change this to the slug of the page you'd like the visitor to be sent to once they save the form.
$return_url = $dashboard_page ? esc_url( get_permalink( $dashboard_page ) ) : '/';
ob_start();
acf_form( array(
'post_id' => 'new_post',
'new_post' => array (
'post_type' => 'event', //change this to your post type
'post_status' => 'publish'
),
'field_groups' => array( 'group_68a37d1a89071' ), //put your field group's Key here
'form' => true,
'uploader' => 'basic',
'submit_value' => __( "Add event", 'invouq' ),
'return' => $return_url,
) );
$html = ob_get_contents();
ob_end_clean();
return $html;
}
add_shortcode( 'add-post-frontend', 'invouq_render_add_post' );
/**
* Render edit post form using [edit-post-frontend] shortcode
*
* @link https://www.advancedcustomfields.com/resources/acf_form/
*
* @return string The form html
*/
function invouq_render_edit_post(): string {
$event_id = isset( $_GET['event_id'] ) ? absint( $_GET['event_id'] ) : 0;
$post = $event_id ? get_post( $event_id ) : null;
if ( ! $post || $post->post_type !== 'event' ) { //change this to your post type
$dashboard_page = get_page_by_path( 'dashboard' ); //change this to the slug of the page you'd like the visitor to be sent to if they fail validation.
if ( $dashboard_page ) {
$dashboard_url = esc_url( get_permalink( $dashboard_page ) );
return sprintf(
"<strong>Whoops! A valid event was not selected. <a id='dashboard-return' class='error-link' href='%s'>Return to the Dashboard</a> and select an event to edit.</strong>",
$dashboard_url
);
}
return "<strong>Whoops! A valid event was not selected. Go to the Dashboard and select an event to edit.</strong>";
}
if ( ! current_user_can( 'edit_posts' ) ) {
return "<strong>You do not have permission to edit an event.</strong>";
}
$dashboard_page = get_page_by_path( 'dashboard' ); //change this to the slug of the page you'd like the visitor to be sent to once they save the form.
$return_url = $dashboard_page ? esc_url( get_permalink( $dashboard_page ) ) : '/';
ob_start();
acf_form( array(
'post_id' => $event_id,
'new_post' => false,
'field_groups' => array( 'group_68a37d1a89071' ), //put your field group's Key here
'form' => true,
'uploader' => 'basic',
'submit_value' => __( "Save event", 'invouq' ),
'return' => $return_url,
) );
$html = ob_get_contents();
ob_end_clean();
return $html;
}
add_shortcode( 'edit-post-frontend', 'invouq_render_edit_post' );
Step 3: Add the Shortcodes
Here’s the good part — putting the forms on their pages is easy; we’ll add one of the two shortcodes created in the code above:
Add New Post Form
Use this shortcode to allow users to create a new post (in this case, an Event):
[add-post-frontend]
A part of our ACF form as rendered on the frontend.
Edit Existing Post Form
Here is the shortcode to edit an existing post. It pulls in the correct post ID via a query parameter (?event_id=123).
[edit-post-frontend]If you need help adding the URL parameter to the list of posts in your dashboard, you can use this shortcode. I use it in a Loop Item template in Elementor Pro, and then display them using a Loop Grid widget. Add this shortcode to your Loop Item template:
/**
* Shortcode to render the Edit Event link for the Elementor Dashboard Loop Item
*
* Usage: [edit-event-link]
*
* @return void
*/
function invouq_render_edit_event_link (): void {
$post_id = get_the_ID();
echo '<a class="edit-event-link" aria-label="Click to edit this Event" href="/edit-event-elementor/?event_id=' . $post_id . '">Edit Event</a>';
}
add_shortcode( "edit-event-link", "invouq_render_edit_event_link" );Bonus CSS
Out of the box, ACF frontend forms look pretty plain. I’ve included some bonus CSS that you can drop into your theme’s stylesheet to make the forms a little nicer. This is a good starting point for you to customize as needed:
<style>
/* overall form container */
.acf-form {
max-width: 800px;
margin: 0 auto;
padding: 20px;
background-color: #ffffff;
border: 1px solid #ccc;
border-radius: 4px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
/* field labels */
.acf-field label {
display: block;
margin-bottom: 8px;
font-weight: bold;
color: #1a1a1a; /* High contrast */
font-size: 16px;
}
/* input fields, textareas, and selects */
.acf-field input[type="text"],
.acf-field input[type="email"],
.acf-field input[type="number"],
.acf-field textarea,
.acf-field select {
width: 100%;
padding: 10px;
margin-bottom: 15px;
border: 1px solid #666;
border-radius: 4px;
box-sizing: border-box;
font-size: 16px;
background-color: #fff;
color: #000;
}
/* focus styles */
input:focus,
textarea:focus,
select:focus,
button:focus {
outline: 2px solid #005fcc;
outline-offset: 2px;
}
/* file input */
.acf-field .acf-file-uploader .button {
background-color: #005fcc;
color: #ffffff;
padding: 10px 15px;
border: none;
border-radius: 4px;
cursor: pointer;
}
/* submit button */
.acf-form input[type="submit"] {
background-color: #007a33;
color: #ffffff;
padding: 12px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
transition: background-color 0.3s ease;
}
.acf-form input[type="submit"]:hover {
background-color: #005f26;
}
/* required field indicators */
.acf-required-indicator {
color: #b00000; /* Dark red for better contrast */
margin-left: 5px;
font-weight: bold;
}
.acf-required-indicator::before {
content: "*";
color: #b00000;
margin-right: 2px;
}
/* validation messages */
.acf-field .acf-notice.-error {
background: #fbeaea;
color: #5c0000;
border: 1px solid #5c0000;
padding: 10px;
border-radius: 4px;
}
/* error messages */
.acf-error-message {
background-color: #5c0000;
color: #ffffff;
font-size: 0.9em;
padding: 8px;
border-radius: 4px;
margin-top: -10px;
margin-bottom: 10px;
}
</style>Step 4: Test and Tweak
Once you’ve added the code and CSS:
- Create a new page called Add Event and add this shortcode:
[add-post-frontend]. - Create another page called Edit Event and add this shortcode:
[edit-post-frontend]. Remember, when visiting the edit page, you’ll need to add a post ID to the ‘event’ GET parameter. The accompanying video will show you. It should look something like this:https://mysite.com/edit-event/?event_id=123 - Visit the pages and test the forms.
You now have a fully functional frontend submission system for WordPress!
Why This Is Better Than the Backend
- No backend complexity: Users never see the WordPress dashboard.
- Works with any builder: Elementor, Gutenberg, Divi — it doesn’t matter.
- Secure: ACF handles nonce validation and permissions.
- Lightweight: No bulky plugins to break with updates.
Final Thoughts
If you’ve been struggling to find a reliable way for users to add or edit posts, this method is simple, stable, and flexible. By tweaking the provided code and using the two shortcodes, you can create a frontend submission system that feels natural and user-friendly.
Bonus: Syncing an ACF Field to the Post’s Title
I was asked how to set the post’s title using one of the ACF fields. The code below sets the post’s title to the value of an ACF “name” field for posts in the “event” custom post-type when a post is saved.
/**
* Sync the post title and slug with the custom ACF "name" field
* for the "event" CPT when a post is saved.
*
* @since 1.0.0
*/
declare( strict_types=1 );
/**
* Sync the post title and slug from an ACF custom "name" field on save.
*
* Prevents infinite recursion using a static guard flag.
*
* @since 1.0.0
*
* @param int|string $post_id The ID of the post being saved.
* @return void
*/
function invouq_sync_custom_name_to_title( int|string $post_id ): void {
if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
return;
}
if ( false === get_post_type( $post_id ) ) {
return; // Bail on non-post (e.g., terms, users) saves.
}
$post = get_post( $post_id );
if ( ! $post ) {
return;
}
// Capability check — ensure the current user can edit this post.
if ( ! current_user_can( 'edit_post', $post_id ) ) {
return;
}
$allowed_post_types = [ 'event' ];
if ( ! in_array( $post->post_type, $allowed_post_types, true ) ) {
return;
}
$custom_name = get_field( 'name', $post_id );
if ( ! $custom_name ) {
return;
}
$clean_custom_name = wp_strip_all_tags( (string) $custom_name );
if ( $post->post_title === $clean_custom_name ) {
return; // 'name' hasn't changed.
}
$new_post_data = [
'ID' => $post_id,
'post_title' => $clean_custom_name,
'post_name' => sanitize_title( $clean_custom_name ),
];
// Recursion guard via static flag (more robust than unhook/rehook).
static $updating = false;
if ( $updating ) {
return;
}
$updating = true;
$result = wp_update_post( $new_post_data, true );
$updating = false;
if ( is_wp_error( $result ) ) {
return;
}
}
/**
* Hook the sync function into ACF's post save action.
*
* @since 1.0.0
*
* @param int|string $post_id The ID of the post being saved.
*/
add_action( 'acf/save_post', 'invouq_sync_custom_name_to_title', 20 );



