Building a Front-End User Profile Editor with GreenShift Forms

Ivan
Ivan
 · 
5,311 words
 · 
28 minutes
reading time
Greenlight Elements includes a very convenient form element block that can be a great little helper in certain cases where running a full-blown forms plugin would be overkill. Here is one such example…

Introduction

I recently saw several discussions in the GreenShift Facebook group where people seemed to have unrealistic expectations about what the native form element in GreenLight can do. Let me be clear up front: GreenShift forms are not replacements for full-featured form plugins like Fluent Forms, Gravity Forms, or WS Forms. They’re not trying to be.

But there are specific use cases where GreenShift’s form blocks are actually the better choice. This tutorial walks through one of those cases: building a front-end profile editor for logged-in users.

The Problem I Was Solving

I’m working on a website for a daycare center where children stay after school for homework help, games, and activities until their parents pick them up. The parents need accounts on the website to receive information about their children and upcoming events.

WordPress gives every user access to edit their profile in wp-admin, but let’s be honest — the WordPress admin dashboard is a confusing mess for anyone who isn’t already familiar with it. I didn’t want to force parents to navigate through that interface just to update their phone number or change their email address.

So I built a simple profile editor right into the front-end, using GreenShift blocks. Parents click an “Edit” button on their profile page, make their changes, and save. No wp-admin required.

Why GreenShift Forms Made Sense Here

Here’s why I chose GreenShift forms over a dedicated forms plugin for this:

Full control over the UI: I’m building the entire page in GreenShift anyway. Having the form as native blocks means I can style it consistently with the rest of the page without fighting against a form plugin’s CSS.

Direct data access: I’m not collecting data to be processed later — I’m directly updating WordPress user data. Form plugins add layers of abstraction that I don’t need here.

No plugin overhead: Why load an entire forms plugin just to handle three fields for logged-in users? That’s overkill.

Simple validation: I only need to validate an email address and ensure the nonce is valid. I don’t need complex conditional logic or multi-step workflows.

The key insight here is that GreenShift forms work best when you’re doing something specific and controlled. Public contact forms? Use a real forms plugin. Complex data collection? Use a real forms plugin. But for targeted user data editing like this? GreenShift forms are perfect.

What We’re Building

By the end of this tutorial, you’ll have:

  • A display panel showing current user information (name, email, phone)
  • An edit form (initially hidden) that appears when users click “Edit”
  • Pre-populated form fields with current data
  • Secure form submission with nonce validation
  • Email change confirmation workflow (with a link sent to the new email)
  • The ability to cancel pending email changes
  • All built with GreenShift blocks and a few PHP/JavaScript snippets

What you’ll need:

  • WordPress site with GreenShift/GreenLight installed
  • Basic understanding of WordPress hooks and PHP
  • A snippet manager like WPCodeBox (or the ability to add code to your theme)
  • Familiarity with the GreenShift block editor

I’m providing all the code as downloadable resources, but I’ll walk through why each piece works the way it does. Understanding the reasoning helps you adapt this to your own needs.

Let’s get started.


Step 1: Building the Interface in GreenShift

Before we write any code, we need to build the actual interface users will interact with. This is all done in the GreenShift block editor — no coding required yet.

The Display Panel

First, I created a panel that shows the user’s current information. Think of this as the “view mode” before they click edit. The panel contains:

  • A block with an icon and the user’s name
  • A block with an icon and their email address
  • A block with an icon and their phone number
  • A block working as an “Edit” button (identically structured to the other three blocks but with the mouse cursor set to ‘pointer’ using the GL local styling tool.

I won’t walk through every styling decision here — make it look however you want. The important part is assigning the right ID to the edit button so JavaScript can find it later:

Edit button ID: toggle-profile-edit

That’s it for the display panel. No special configuration needed for the text elements — we’ll populate those with JavaScript.

The Edit Form Container

Now comes the more involved part: building the actual edit form. I created a second container (separate from the display panel) that holds the form. This container starts hidden — we set display: none in GreenShift’s styling panel.

Here’s the structure I built using GreenShift’s form blocks (note: I have provided the entire structure of Gutenberg blocks as a snippet that you can copy/paste into a blank page for further testing, but for the time being, let’s use the HTML output for better clarity):

<form method="post" action="/wp-admin/admin-post.php">
    <input type="hidden" name="action" value="edit_user_profile">
    <input type="hidden" name="profile_edit_nonce" id="profile-edit-nonce">
    
    <!-- Name field with icon -->
    <input name="edit-profile-data-name" id="edit-profile-data-name" placeholder="Full Name">
    
    <!-- Email field with icon -->
    <input name="edit-profile-data-email" id="edit-profile-data-email" type="email" placeholder="your@email.com">
    
    <!-- Phone field with icon -->
    <input name="edit-profile-data-phone" id="edit-profile-data-phone" type="tel" placeholder="Phone Number">
    
    <!-- Submit and Cancel buttons -->
    <input type="submit" id="submit-profile-edit" value="Save Changes">
    <input type="button" id="cancel-profile-edit" value="Cancel">
</form>
HTML

Let me explain the critical parts:

The form action: This MUST point to /wp-admin/admin-post.php. This is WordPress’s endpoint for processing custom form submissions. If you leave this empty or point it elsewhere, nothing will work.

The hidden action field: WordPress uses this to figure out which handler to call. The name must be “action” and the value must match what we’ll use in our PHP hook later. I used edit_user_profile.

The hidden nonce field: This is our security token. Notice it has an ID but no value — JavaScript will fill in the value on page load.

Field naming consistency: The name attributes (edit-profile-data-name, edit-profile-data-email, etc.) must match what our PHP handler expects. And the id attributes must match what our JavaScript looks for. Keep these consistent.

The container ID: The entire edit form container needs an ID so JavaScript can show/hide it. I used edit-profile-data.

Button types: The submit button is type="submit" (obviously), but the cancel button is type="button". This is important — we don’t want the cancel button to submit the form.

The Email Change Warning

There’s one more piece to build: a warning message that appears when someone has initiated an email change but hasn’t confirmed it yet. This is also hidden by default:

<div id="edit-profile-data-email-message" style="display:none;">
    Warning: You have a pending email change request. 
    <a href="#" id="edit-profile-data-email-cancel">Click here to cancel</a>
</div>
HTML

This gives users clear feedback about pending changes and a way to cancel them.

Styling Considerations

A few things I learned about styling GreenShift forms:

Forms don’t inherit fonts by default: You’ll need to explicitly set font-family: inherit on your form fields. I added this as custom CSS:

.custom-data-edit-field {
    font-family: inherit;
}

.custom-data-edit-button {
    font-family: inherit;
    cursor: pointer;
}
CSS

Button styling is stubborn: Input buttons have strong browser defaults. Sometimes you need to force inheritance on the container too.

Mobile considerations: If you want different layouts on mobile (which you probably do), use GreenShift’s responsive controls or add custom media queries.

IDs and Names Reference

Here’s a quick reference of all the important IDs and names you need to set correctly:

ElementIDName
Edit buttontoggle-profile-edit
Edit containeredit-profile-data
Hidden action fieldactionaction
Hidden nonce fieldprofile-edit-nonceprofile_edit_nonce
Name inputedit-profile-data-nameedit-profile-data-name
Email inputedit-profile-data-emailedit-profile-data-email
Phone inputedit-profile-data-phoneedit-profile-data-phone
Submit buttonsubmit-profile-edit
Cancel buttoncancel-profile-edit
Email warning messageedit-profile-data-email-message
Cancel email linkedit-profile-data-email-cancel

Double-check these. If the IDs don’t match what JavaScript expects or the names don’t match what PHP expects, nothing will work.

That’s it for the interface. Save your page and let’s move on to making it actually do something.


Step 2: Getting User Data to the Front-End

So here’s the challenge I faced: the form needs to know what the user’s current name, email, and phone number are so it can pre-fill those fields. But how do you get that data from WordPress (which lives in PHP land) over to JavaScript (which handles the interactive bits)?

I could have made AJAX calls to fetch the data, but that seemed unnecessarily complicated and would add extra server requests. Instead, I went with a simpler approach: output the data as a hidden div with HTML5 data attributes right when the page loads. JavaScript can then just read those attributes whenever it needs them. Clean, simple, no extra HTTP requests.

Setting Up Configuration

When I write code, I try to avoid hard-coding parameters inside function calls or function bodies. Instead, I try to keep things more abstract and adaptable to changes. In this case, I’ve added two data arrays that can be used to determine which pages would need this functionality based on their slugs or IDs. It’s a bit of overkill for this overly specific use case, but I’m keeping it as a learning principle:

define('TARGET_PAGE_SLUGS', ['visitors']); // Array of page slugs
define('TARGET_PAGE_IDS', []); // Optional: specific page IDs
PHP

This way, if I later decide to add this profile editor to another page, I just update the array instead of hunting through the code for hard-coded values.

Injecting the Data Container

I hook into wp_body_open because it fires early — right after the opening <body> tag — which means the data container will be available before any of my JavaScript tries to access it. Timing matters here.

add_action('wp_body_open', function() {
    // Only output for logged-in users
    if (!is_user_logged_in()) {
        return;
    }
    
    // Check if current page matches target slugs or IDs
    $current_page_slug = get_post_field('post_name', get_queried_object_id());
    $current_page_id = get_queried_object_id();
    
    $is_target_page = in_array($current_page_slug, TARGET_PAGE_SLUGS) || 
                      in_array($current_page_id, TARGET_PAGE_IDS);
    
    if (!$is_target_page) {
        return;
    }
    
    // ... rest of the code
});
PHP

The first check is obvious — we don’t need this data for visitors who aren’t logged in. The second check ensures we’re only running this code on the pages that actually need it. No point in outputting user data on every single page of the site.

Now comes the actual data gathering. I pull the current user’s display name and email from WordPress’s user object, and grab the phone number from user meta. In my case, I’m storing phone numbers in a custom meta field called phone_no — you’ll need to adjust this to match whatever field name you’re using:

$current_user = wp_get_current_user();
$user_id = $current_user->ID;
$display_name = $current_user->display_name;
$email = $current_user->user_email;
$phone = get_user_meta($user_id, 'phone_no', true);
PHP

There’s one more piece of data I need to check: whether the user has a pending email change. WordPress stores this in the _new_email user meta when someone initiates an email change but hasn’t confirmed it yet. I need to know about this so I can disable the email field and show a warning message:

$pending_email_data = get_user_meta($user_id, '_new_email', true);
$pending_email = '';

if ($pending_email_data && isset($pending_email_data['newemail'])) {
    $pending_email = $pending_email_data['newemail'];
}
PHP

Next up is the security token — the nonce. I generate it here with the action name 'edit_profile_action', which I’ll verify later when processing the form submission:

$nonce = wp_create_nonce('edit_profile_action');
PHP

Finally, I output everything as a hidden div with HTML5 data attributes. Notice I’m using esc_attr() on everything — you should always escape data going into HTML attributes to prevent injection attacks:

echo '<div id="user-profile-data" style="display:none;" 
          data-user-id="' . esc_attr($user_id) . '"
          data-nonce="' . esc_attr($nonce) . '"
          data-display-name="' . esc_attr($display_name) . '" 
          data-email="' . esc_attr($email) . '" 
          data-phone="' . esc_attr($phone) . '"
          data-pending-email="' . esc_attr($pending_email) . '"></div>';
PHP

That’s it. When the page loads, this hidden div sits quietly in the DOM with all the user data my JavaScript will need. No database queries from the client side, no AJAX complexity — just clean, efficient data transfer from server to browser.


Step 3: Making the Form Interactive with JavaScript

Some JavaScript is required to support the functionality we’ve built, and this JS needs to be loaded on the necessary pages. I use the incredibly helpful snippet manager WPCodeBox which can insert JS snippets directly, and this is why I’m providing only the actual JS code. Depending on your toolkit, you may need to wrap this in <script>...</script> tags and insert it as an HTML snippet, or echo it as a PHP snippet. The complete JavaScript code is available as a separate download at the end of this tutorial.

The JavaScript handles three main jobs: showing and hiding the edit form, pre-populating the fields with current data, and managing the pending email change state. Let me walk through each piece.

Toggling the Edit Form

The simplest part is just showing the edit container when someone clicks the “Change” button. I wrapped this in its own DOMContentLoaded listener to keep things organized:

document.addEventListener('DOMContentLoaded', function() {
    const toggleBtn = document.getElementById('toggle-profile-edit');
    const editorContainer = document.getElementById('edit-user-data');
    
    if (toggleBtn && editorContainer) {
        toggleBtn.addEventListener('click', function(e) {
            e.preventDefault();
            editorContainer.style.display = 'block';
        });
    }
});
JavaScript

Nothing fancy here — just a click handler that sets display: block on the edit container. The e.preventDefault() is important because the button is technically a link, and we don’t want it navigating anywhere.

Pre-populating Form Fields

This is where that hidden data container we created in PHP comes into play. The JavaScript reads all those data attributes and uses them to fill in the form fields:

document.addEventListener('DOMContentLoaded', function() {
    // Get data from backend container
    const profileData = document.getElementById('user-profile-data');
    if (!profileData) return;
    
    const displayName = profileData.dataset.displayName;
    const email = profileData.dataset.email;
    const phone = profileData.dataset.phone;
    const pendingEmail = profileData.dataset.pendingEmail;
    const nonce = profileData.dataset.nonce;
JavaScript

The dataset property is a really clean way to access data attributes — data-display-name becomes dataset.displayName automatically. If the container doesn’t exist (maybe we’re on the wrong page), we just bail out early with that return.

Now I grab references to all the form elements I need to work with:

    const nameInput = document.getElementById('edit-profile-data-name');
    const emailInput = document.getElementById('edit-profile-data-email');
    const phoneInput = document.getElementById('edit-profile-data-phone');
    const nonceInput = document.getElementById('profile-edit-nonce');
    const pendingMessage = document.getElementById('edit-profile-data-email-message');
    const cancelBtn = document.getElementById('cancel-profile-edit');
    const editContainer = document.getElementById('edit-profile-data');
    const cancelEmailLink = document.getElementById('edit-profile-data-email-cancel');
JavaScript

Then comes the actual pre-population. I fill in the name, phone, and nonce fields immediately:

    if (nameInput) nameInput.value = displayName;
    if (phoneInput) phoneInput.value = phone;
    if (nonceInput) nonceInput.value = nonce;
JavaScript

The email field is more complicated because of the pending change logic. If there’s a pending email change, I need to disable the field, show the new (pending) email address, and display the warning message. Otherwise, just show the current email and let them edit it:

    if (pendingEmail) {
        // User has pending email change - disable field and show new email
        emailInput.disabled = true;
        emailInput.value = pendingEmail;
        if (pendingMessage) pendingMessage.style.display = 'block';
    } else {
        // No pending change - show current email and allow editing
        emailInput.disabled = false;
        emailInput.value = email;
        if (pendingMessage) pendingMessage.style.display = 'none';
    }
JavaScript

This gives users clear feedback about the state of their email change request. They can see what email they’re changing to, but they can’t modify it until they either confirm or cancel the pending change.

Handling the Cancel Button

The cancel button just hides the edit form without saving anything:

    if (cancelBtn && editContainer) {
        cancelBtn.addEventListener('click', function() {
            editContainer.style.display = 'none';
        });
    }
JavaScript

Simple, but important — users need a way to back out if they change their mind.

Canceling a Pending Email Change

This is the most complex piece of JavaScript because it makes an AJAX call back to WordPress. When someone clicks the cancel link in the pending email warning, we need to remove that pending change from the database:

    if (cancelEmailLink) {
        cancelEmailLink.addEventListener('click', function(e) {
            e.preventDefault();
            
            // Send AJAX request to remove pending email change
            fetch('/wp-admin/admin-post.php', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/x-www-form-urlencoded',
                },
                body: new URLSearchParams({
                    'action': 'cancel_email_change',
                    'nonce': nonce
                })
            })
            .then(response => response.json())
            .then(data => {
                if (data.success) {
                    // Reload page to refresh form state
                    window.location.reload();
                }
            });
        });
    }
});
JavaScript

I’m using the Fetch API here (which is supported in all modern browsers). The request goes to WordPress’s admin-post.php endpoint with the action name cancel_email_change and our security nonce. If the server responds with success, I just reload the page — it’s the simplest way to refresh all the form state and make sure everything is in sync.

One important note about variable scope: notice how the nonce variable is defined at the top of this DOMContentLoaded block. That means it’s accessible to all the event handlers inside this block, including the cancel email link handler. If I’d defined it inside one of the if statements, the other handlers wouldn’t be able to see it.

That’s all the JavaScript we need. The form now shows and hides properly, fields pre-populate with current data, and users can cancel pending email changes. The rest of the work happens on the server side when the form actually gets submitted.


Step 4: Processing the Form Submission

When someone clicks the “Save Changes” button, the form posts to WordPress’s admin-post.php endpoint. Now we need to write the handler that processes that submission, updates the user data, and handles the email change workflow.

The form includes a hidden field with name="action" and value="edit_user_profile". WordPress uses this to figure out which hook to fire. So I register my handler on the admin_post_edit_user_profile hook:

add_action('admin_post_edit_user_profile', function() {
PHP

Security First

The very first things I check are authentication and nonce verification. Never trust that a form submission is legitimate just because it arrived at your endpoint:

// Verify user is logged in
if (!is_user_logged_in()) {
  wp_die('Unauthorized access');
}
    
// Verify nonce
if (!isset($_POST['profile_edit_nonce']) || !wp_verify_nonce($_POST['profile_edit_nonce'], 'edit_profile_action')) {
  wp_die('Security verification failed');
}
PHP

If either of these checks fails, I immediately die with an error message. No point in processing any further.

Getting the Data

Now I can safely grab the current user and the submitted form data:

$current_user_id = get_current_user_id();
$current_user = get_userdata($current_user_id);
    
// Sanitize inputs
$new_display_name = isset($_POST['edit-profile-data-name']) ? sanitize_text_field($_POST['edit-profile-data-name']) : '';
$new_email = isset($_POST['edit-profile-data-email']) ? sanitize_email($_POST['edit-profile-data-email']) : '';
$new_phone = isset($_POST['edit-profile-data-phone']) ? sanitize_text_field($_POST['edit-profile-data-phone']) : '';
PHP

Notice the sanitization functions — sanitize_text_field() for the name and phone, sanitize_email() for the email. Always sanitize user input. Always.

Updating the Simple Fields

Name and phone are straightforward — just check if they’ve changed and update if needed:

// Update display name if changed
if ($new_display_name && $new_display_name !== $current_user->display_name) {
  wp_update_user(array(
    'ID' => $current_user_id,
    'display_name' => $new_display_name
  ));
}
    
// Update phone number
if ($new_phone) {
  update_user_meta($current_user_id, 'phone_no', $new_phone);
}
PHP

I only update the display name if it’s actually different from the current one — no point in hitting the database unnecessarily. The phone number goes into user meta with whatever field name you’re using (phone_no in my case).

Email Changes: The Complicated Part

Email changes require extra care because changing someone’s email without confirmation is a security risk. Someone could hijack an account just by changing the email to one they control. So WordPress (and we should too) requires confirmation via email before actually making the change.

First, I check if the email has actually changed:

if ($new_email && $new_email !== $current_user->user_email) {
PHP

Then I validate it. This is belt-and-suspenders — sanitize_email() already cleaned it, but I want to make absolutely sure it’s a valid email format:

if (!is_email($new_email)) {
  wp_die('Invalid email address');
}
PHP

Next, I check if this email is already being used by another user. WordPress doesn’t allow duplicate emails, so this would fail anyway, but I’d rather catch it here with a clear error message:

if (email_exists($new_email) && email_exists($new_email) !== $current_user_id) {
  wp_die('Email address already in use');
}
PHP

Now comes the confirmation workflow. I generate a unique hash that will serve as the confirmation token:

$hash = md5($new_email . time() . wp_rand());
PHP

This hash combines the new email, current timestamp, and a random number. It doesn’t need to be cryptographically perfect — it just needs to be unique and unguessable.

I store this pending change in user meta using WordPress’s standard _new_email meta key:

update_user_meta($current_user_id, '_new_email', array(
  'hash' => $hash,
  'newemail' => $new_email
));
PHP

The underscore prefix makes it a “private” meta field — it won’t show up in the normal custom fields interface.

Now I send the confirmation email. I build a URL that includes the hash as a query parameter, pointing back to the profile page:

$sitename = wp_specialchars_decode(get_option('blogname'), ENT_QUOTES);
$confirmation_url = add_query_arg('newuseremail', $hash, home_url('/visitors/'));
        
$email_text = sprintf(
  __('Hello %s,

You recently requested to change the email address on your account.
If you made this request, please click the following link to confirm:

%s

You can safely ignore this email if you do not want to make this change.

Regards,
%s
%s', 'textdomain'),
  $current_user->user_login,
  $confirmation_url,
  $sitename,
  home_url()
);
        
wp_mail(
  $new_email,
  sprintf(__('[%s] Email Change Request', 'textdomain'), $sitename),
  $email_text
);
PHP

The email goes to the new address (not the current one), so the user has to have access to that inbox to complete the change.

Wrapping Up

Finally, regardless of what happened, I redirect the user back to the profile page:

  wp_redirect(home_url('/visitors/'), 303);
  exit;
});
PHP

The 303 status code is the proper one for POST-Redirect-GET patterns — it tells the browser to make the next request with GET, not POST. And always exit after a redirect to make sure no other code runs.


Step 5: Completing the Email Change Workflow

So we’ve sent the confirmation email with a link containing the hash. Now we need two more handlers: one to process the confirmation when someone clicks that link, and another to handle cancellation if they change their mind.

Processing Email Confirmations

This one’s a bit different because it doesn’t use admin-post.php. Instead, it hooks into init and watches for a specific query parameter:

add_action('init', function() {
    // Handle email confirmation
    if (isset($_GET['newuseremail']) && is_user_logged_in()) {
PHP

When someone clicks the confirmation link, WordPress loads the page with ?newuseremail=thehashvalue in the URL. That’s my signal to process the confirmation.

I grab and sanitize the hash:

$hash = sanitize_text_field($_GET['newuseremail']);
$current_user_id = get_current_user_id();
PHP

Then I fetch the pending email data from user meta:

$pending_email_data = get_user_meta($current_user_id, '_new_email', true);

if (!$pending_email_data || !isset($pending_email_data['hash'])) {
  wp_die('Invalid or expired confirmation link');
}
PHP

If there’s no pending change, or the data is malformed, this is either an invalid link or someone messing around.

Next, I verify the hash matches what we stored:

if ($pending_email_data['hash'] !== $hash) {
  wp_die('Invalid confirmation link');
}
PHP

This is the critical security check — only someone with the exact hash we generated can complete this change.

If everything checks out, I actually update the email:

wp_update_user(array(
  'ID' => $current_user_id,
  'user_email' => $pending_email_data['newemail']
));
PHP

Clean up the pending change meta:

delete_user_meta($current_user_id, '_new_email');
PHP

And redirect back to the profile page with a success indicator:

    wp_redirect(home_url('/visitors/?email_updated=1'));
    exit;
  }
});
PHP

You might be wondering why we need this manual handler at all. Doesn’t WordPress have built-in email confirmation? Yes, but only in the admin dashboard (wp-admin/profile.php). On the front-end, we have to roll our own.

Handling Email Change Cancellation

This one uses admin-post.php because it’s triggered by an AJAX call from our JavaScript. Remember that cancel link in the pending email warning? This is what it calls:

add_action('admin_post_cancel_email_change', 'handle_cancel_email_change');
add_action('admin_post_nopriv_cancel_email_change', 'handle_cancel_email_change');

function handle_cancel_email_change() {
PHP

Notice I’m registering the handler on two hooks: admin_post_cancel_email_change and admin_post_nopriv_cancel_email_change. Here’s why: even though the user is logged in, AJAX requests to admin-post.php sometimes get routed through the nopriv hook due to quirks in how WordPress handles cookies in AJAX contexts. Registering both ensures it works reliably.

Same security checks as always:

if (!is_user_logged_in()) {
  wp_send_json_error('Unauthorized');
}

if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'edit_profile_action')) {
  wp_send_json_error('Invalid nonce');
}
PHP

But instead of wp_die(), I’m using wp_send_json_error() because this is an AJAX handler — I need to return JSON that the JavaScript can parse.

The actual cancellation is dead simple — just delete the pending email meta:

  $current_user_id = get_current_user_id();
  delete_user_meta($current_user_id, '_new_email');

  wp_send_json_success();
}
PHP

No email change, no hash, just remove it. The JavaScript receives the success response and reloads the page to refresh the form state.


Step 6: Things I Learned (and You Should Know)

Building this taught me a few things that aren’t immediately obvious from the WordPress documentation. Some of these are quirks, some are best practices, and some are just things that tripped me up along the way.

How WordPress Nonces Actually Work

I used to think WordPress nonces were like traditional nonces — used once and done. They’re not. They’re actually stateless cryptographic hashes generated from:

  • A time chunk (12-hour windows)
  • The action name
  • The user ID
  • The session token

This means they’re valid for 12-24 hours and aren’t stored anywhere. When WordPress verifies a nonce, it just regenerates the hash and compares. This also means:

  • Multiple nonces on the same page work fine — they’re independent
  • They protect against CSRF (cross-site request forgery) attacks
  • They DON’T protect against authenticated users doing bad things within their permissions
  • Logout invalidates all nonces for that user
  • They’re user-specific — User A’s nonce won’t work for User B

You still need capability checks and rate limiting. Nonces are just one layer of security.

GreenShift Forms Don’t Inherit Fonts

This drove me crazy for longer than I’d like to admit. GreenShift form fields don’t automatically inherit font-family from their parent containers. You need to explicitly set it:

.acf-form-fields {
    font-family: inherit;
}

.acf-form-submit input[type="submit"] {
    font-family: inherit;
    cursor: pointer;
}
CSS

Input buttons are especially stubborn — sometimes you need to target the container too:

{CURRENT} * {
    font-family: inherit;
}
CSS

The Form Action Attribute Matters

This seems obvious in retrospect, but when I first built this, I left the form action empty thinking it would just post to the current page. Wrong. GreenShift forms need an explicit action:

<form method="post" action="/wp-admin/admin-post.php">
HTML

Without it, you get unpredictable behavior depending on the page structure.

Hidden Fields Need Both ID and Value

Another “should have been obvious” gotcha: the hidden action field needs a value attribute, not just an ID:

<!-- Wrong -->
<input type="hidden" name="action" id="action">

<!-- Right -->
<input type="hidden" name="action" id="action" value="edit_user_profile">
HTML

WordPress looks for the value, not the ID.

WPCodeBox Snippet Execution Context

If you’re using WPCodeBox (or a similar snippet manager), make sure PHP snippets that handle admin_post hooks are set to run on both frontend and admin, not just on the frontend. The admin-post.php endpoint is technically in the admin context even though you’re calling it from the front-end. This tripped me up during testing — the handler just wouldn’t fire until I changed this setting.

Email Deliverability is Critical

WordPress’s default wp_mail() function uses PHP’s mail(), which many hosting providers have configured poorly (or blocked entirely). Use a proper SMTP plugin like FluentSMTP or WP Mail SMTP. Configure it with a real SMTP service (Gmail, SendGrid, Mailgun, whatever) so that your confirmation emails will actually reach users.

The Phone Field Name

Throughout this tutorial I’ve used phone_no as the meta key for phone numbers. This is specific to my setup. Make sure you adjust this to match whatever field name you’re actually using:

$phone = get_user_meta($user_id, 'your_actual_field_name', true);
PHP

Check your existing user meta or create the field with your preferred naming convention.

Browser Compatibility Note

The JavaScript uses the Fetch API, which works in all modern browsers but not in Internet Explorer 11. If you need IE11 support (and honestly, you probably don’t in 2024), you’ll need to use XMLHttpRequest instead or include a fetch polyfill.


Wrapping Up

What started as “I need parents to edit their profiles without seeing wp-admin” turned into a pretty comprehensive exploration of WordPress form handling, security, and the practical limits of what you can do with block-based page builders.

Here’s what we built:

  • A clean, front-end profile editor using only GreenShift blocks
  • Secure form processing with proper nonce validation
  • Email change confirmation workflow (because security matters)
  • AJAX-powered cancellation (because users change their minds)
  • All without touching the WordPress admin dashboard

When this approach makes sense:

  • You’re already using GreenShift/GreenLight heavily
  • You need simple, targeted user data editing
  • You want full control over the UI without form plugin limitations
  • The forms are for logged-in users only

When you should use a real forms plugin instead:

  • Complex multi-step forms
  • Payment processing
  • File uploads
  • Public-facing contact forms
  • Advanced conditional logic
  • Need form analytics and tracking

GreenShift forms aren’t trying to replace Fluent Forms or Gravity Forms — and that’s fine. They’re perfect for this specific use case: letting logged-in users update their own data in a controlled, secure way.

The complete PHP and JavaScript code, as well as the HTML structure for the form are available for download via the links below. You can grab these, adjust the field names and page slugs to match your setup, and have this running on your site in minutes.

I might not be able to help you troubleshoot if you run into issues but I will appreciate knowing if this tutorial has worked for you or not.

Happy building!

This website uses cookies to enhance your browsing experience and ensure the site functions properly. By continuing to use this site, you acknowledge and accept our use of cookies.

Accept All Accept Required Only