Blog

Advanced Human-in-the-Loop Workflows in n8n with Custom HTML Forms

n8n's native HITL tools handle simple yes/no decisions just fine. But the moment you need editable pre-populated forms, reviewer notes, or anything approaching a real content review β€” the cracks start to show. This article covers a 2-workflow architecture that actually holds up.

You've built an n8n workflow that generates AI blog posts, processes complex quotes, or prepares customer reports. At some point a human needs to look at the output before it goes anywhere. n8n has a Wait node and basic form triggers, and sure, they work β€” but anyone who's actually tried to build a real review workflow with them knows they start cracking pretty quickly.

The Wait node holds your workflow execution open for hours or even days. One server restart and the pending approval is just gone. Native n8n forms can't pre-populate fields with data from earlier nodes. And Slack buttons are fine for a quick approve/reject, but there's no way for someone to actually edit AI-generated text before sending it back.

This guide walks through a pattern that avoids all of that: n8n generates a custom HTML form, deploys it to customjs.space as a hosted page, sends the URL to whoever needs to review it, and then a completely separate webhook workflow catches the submission when they're done. No fragile long-running executions, no rigid form builders.

TL;DR

  • n8n's built-in HITL tools (Wait node, basic forms) break down for real approval workflows β€” no pre-filled fields, fragile to restarts.
  • Workflow 1 builds a dynamic HTML form, deploys it to customjs.space, notifies the reviewer with a link.
  • The reviewer gets a fully custom, pre-populated form β€” edit text, choose a decision, leave notes.
  • On submit, a fetch() call fires the payload into Workflow 2's webhook trigger.
  • Both workflows are short-lived. No hanging executions, no state stored in n8n memory.

n8n Community Template

Host an AI Gmail reply approval inbox with OpenAI and CustomJS

FreePublished 2026-05-263 views
Open Template β†’

Build super flexible and customizable Human-in-the-Loop (HITL) processes with n8n and CustomJS. Instead of letting AI reply autonomously, this workflow fetches data, drafts responses, and hosts interactive approval pages via CustomJS β€” ensuring full human quality control over your automations with optional custom domain hosting.

Lane 1 β€” Draft Generator

Fetches Gmail, filters no-reply addresses, generates an AI draft with OpenAI, builds a Tailwind approval dashboard and upserts it to CustomJS.

Lane 2 β€” Response Handler

Webhook catches the approved reply, sends it as a threaded Gmail response, and marks the original email as read.

n8n HITL workflow template screenshot showing the AI Email Reply Assistant with two lanes

The two-lane workflow: AI draft generation + CustomJS hosting on the left, webhook approval handler on the right

Where n8n's Built-in HITL Tools Fall Short

n8n does have human-in-the-loop support. The Wait node pauses execution until a webhook resumes it. The Form Trigger can collect user input. Slack and Teams nodes can push approval buttons. These are all legitimate tools and for certain use cases they work perfectly well.

But they weren't really built for the scenario where a human needs to read through AI-generated content, make edits, leave a note for the team, and only then send it onward. That's a different kind of interaction:

ToolGood forBreaks when
Wait NodeShort pauses, same-session approvalsServer restarts, multi-day review cycles
Form TriggerCollecting fresh inputCan't pre-fill fields with existing data
Slack ButtonsBinary approve/rejectNo room to edit content before deciding
Email + LinkNotifying reviewersStill needs something at the other end to collect input

None of them were built for reviewing and editing AI output β€” which is increasingly the actual use case. That's the gap this pattern fills.

The 2-Workflow Setup

Instead of one long workflow that pauses halfway through waiting for a human, you split it in two. Workflow 1 does the generating and notifying. Workflow 2 handles whatever comes back from the reviewer. They never directly talk to each other β€” the only connection is the webhook URL baked into the form.

// WORKFLOW 1: The Generator
[Trigger] β†’ AI generates output (blog post, quote, report)
↓
[HTML Node] β†’ Inject data into HTML form template
↓
[Upsert HTML Page (CustomJS)] β†’ host page β†’ get unique URL
↓
[Notify] β†’ Send URL via Slack / Email / Teams
↓
workflow ends β€” no waiting
// WORKFLOW 2: The Receiver (always listening)
[Webhook] β†’ Fires when human submits the form
↓
[Switch] β†’ Route by decision (approve / revise / reject)
↓
[Publish] β†’ Write to CMS / CRM / database
↓
βœ“ Done

Workflow 1 finishes in a few seconds β€” no hanging execution, no open connection. Workflow 2 just sits there waiting for a webhook, which could fire 10 minutes later or three days later. Doesn't matter. The state isn't stored in n8n at all, it lives in the URL and the page hosted on customjs.space.

Why this survives server restarts

A Wait node stores its pending state in n8n's database. If n8n crashes or gets updated mid-review, long-pending executions tend to get lost or corrupted. Here, the pending state lives in the customjs-hosted URL β€” a static page that stays up regardless of what happens to n8n. When the reviewer finally submits, Workflow 2 simply starts fresh from the incoming webhook.

Phase A: Workflow 1 β€” Building the Review Interface

Workflow 1 has one job: take whatever data came out of your automation β€” an AI draft, a calculated quote, a processed document β€” and turn it into a hosted, interactive review page that a real human can open in a browser.

Step 1: Build the HTML form in the HTML Node

Use n8n's HTML node to construct the review form. You inject the dynamic data directly into the template using expression syntax β€” the AI draft goes straight into a textarea, the title into an input, and so on:

// n8n HTML Node β€” build the review form template
const aiDraft = $('AI Agent').first().json.content;
const postTitle = $('AI Agent').first().json.title;
const webhookUrl = 'https://your-n8n.com/webhook/review-submit';

const htmlForm = `
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Review: ${postTitle}</title>
  <script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gray-50 min-h-screen p-8">
  <div class="max-w-2xl mx-auto bg-white rounded-2xl shadow-lg p-8">
    <div class="flex items-center gap-3 mb-6">
      <span class="text-2xl">✏️</span>
      <h1 class="text-xl font-bold text-gray-900">AI Draft β€” Needs Your Review</h1>
    </div>

    <div id="status" class="hidden mb-4 p-4 rounded-lg text-sm font-medium"></div>

    <form id="review-form" class="space-y-5">
      <div>
        <label class="block text-sm font-semibold text-gray-700 mb-1">Title</label>
        <input
          type="text"
          name="title"
          value="${postTitle.replace(/"/g, '&quot;')}"
          class="w-full border border-gray-300 rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
        />
      </div>

      <div>
        <label class="block text-sm font-semibold text-gray-700 mb-1">Content</label>
        <textarea
          name="content"
          rows="12"
          class="w-full border border-gray-300 rounded-lg px-4 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-indigo-500"
        >${aiDraft.replace(/</g, '&lt;').replace(/>/g, '&gt;')}</textarea>
      </div>

      <div>
        <label class="block text-sm font-semibold text-gray-700 mb-1">Decision</label>
        <select name="decision" class="w-full border border-gray-300 rounded-lg px-4 py-2 text-sm">
          <option value="approve">βœ… Approve & Publish</option>
          <option value="revise">πŸ” Needs Revision</option>
          <option value="reject">❌ Reject</option>
        </select>
      </div>

      <div>
        <label class="block text-sm font-semibold text-gray-700 mb-1">Notes for the team (optional)</label>
        <input
          type="text"
          name="notes"
          placeholder="e.g. Fixed intro paragraph, removed duplicate section"
          class="w-full border border-gray-300 rounded-lg px-4 py-2 text-sm"
        />
      </div>

      <button
        type="submit"
        id="submit-btn"
        class="w-full bg-indigo-600 hover:bg-indigo-700 text-white font-semibold py-3 rounded-xl transition-colors"
      >
        Submit Review
      </button>
    </form>
  </div>

  <script>
    document.getElementById('review-form').addEventListener('submit', async (e) => {
      e.preventDefault();
      const btn = document.getElementById('submit-btn');
      const status = document.getElementById('status');

      btn.disabled = true;
      btn.textContent = 'Submitting…';

      const formData = new FormData(e.target);
      const payload = Object.fromEntries(formData.entries());
      payload.submittedAt = new Date().toISOString();
      payload.reviewerAgent = navigator.userAgent;

      try {
        const res = await fetch('${webhookUrl}', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(payload)
        });

        if (res.ok) {
          status.className = 'block mb-4 p-4 rounded-lg text-sm font-medium bg-green-50 text-green-700 border border-green-200';
          status.textContent = 'βœ“ Review submitted. The workflow will continue automatically.';
          e.target.style.opacity = '0.5';
          e.target.style.pointerEvents = 'none';
          btn.textContent = 'Submitted';
        } else {
          throw new Error('Server error');
        }
      } catch (err) {
        status.className = 'block mb-4 p-4 rounded-lg text-sm font-medium bg-red-50 text-red-700 border border-red-200';
        status.textContent = 'βœ— Submission failed. Please try again.';
        btn.disabled = false;
        btn.textContent = 'Submit Review';
      }
    });
  </script>
</body>
</html>
`;

return { json: { html: htmlForm } };

Step 2: Publish with the "Upsert HTML Page" CustomJS Node

After the HTML node, drop in the native Upsert HTML Page (CustomJS) node from the CustomJS n8n package. No HTTP requests to wire up manually β€” just connect your CustomJS credentials and fill in three fields:

// Upsert HTML Page (CustomJS) β€” node fields:

HTML Content:   {{ $json.html }}
Page Name:      review-{{ $json.jobId }}
TTL (seconds):  604800   ← link valid for 7 days

// The node returns:
{
  "url": "https://customjs.space/p/a8f2b...",
  "pageId": "review-abc123",
  "expiresAt": "2026-06-09T..."
}

The url in the output is what you send to your reviewer. Each execution with the same Page Name overwrites the previous version β€” useful if you want to update the draft before the reviewer opens it.

Step 3: Send the link

Last step in Workflow 1 is a notification via Slack, Email, or Teams. Include the URL and an expiry note, then the workflow is done. Whole thing runs in about 3–4 seconds:

// Slack node β†’ Send a message
{
  "channel": "#content-review",
  "text": "πŸ“ *AI Draft Ready for Review*\n\n*Title:* {{ $('AI Agent').first().json.title }}\n\nPlease review and approve here:\n{{ $json.url }}\n\n_Link valid for 7 days._"
}

And that's it for Workflow 1. No open execution sitting around waiting.

Phase B: What the Reviewer Actually Sees

The reviewer clicks the link from Slack and gets a clean, pre-filled form hosted on customjs.space. They can edit the text, pick a decision, leave notes. Below is a live simulation you can interact with:

πŸ”’ customjs.space/p/ai-review-a8f2b
✏️

AI Draft β€” Needs Your Review

Live simulation β€” no real webhooks called

Phase C: Workflow 2 β€” Catching the Submission

Workflow 2 is pretty straightforward. A Webhook node sits dormant until a review form is submitted. One thing worth getting right here is the response mode:

// Webhook node settings
{
  "httpMethod": "POST",
  "path": "review-submit",
  "responseMode": "immediately",
  "responseData": "{ \"received\": true }"
}

Set responseMode to immediately. The HTML form's fetch() call is waiting for a response β€” if n8n takes 10 seconds processing downstream nodes before responding, the reviewer is staring at a loading spinner and wondering if something broke.

Routing by Decision

After the webhook, add a Switch node that routes based on the decision field:

// Switch node rules:
// Rule 1 β†’ Output 1: {{ $json.decision === 'approve' }}
// Rule 2 β†’ Output 2: {{ $json.decision === 'revise' }}
// Rule 3 β†’ Output 3: {{ $json.decision === 'reject' }}

// "approve" branch β€” CMS publish via HTTP Request:
{
  "method": "POST",
  "url": "https://your-cms.com/api/posts",
  "headers": {
    "Authorization": "Bearer YOUR_TOKEN",
    "Content-Type": "application/json"
  },
  "body": {
    "title": "{{ $json.title }}",
    "content": "{{ $json.content }}",
    "status": "published",
    "publishedBy": "human-review",
    "reviewNotes": "{{ $json.notes }}"
  }
}

Looping Back for Revisions

If the reviewer requests changes, you can re-trigger the AI node with their notes and send a new review link. This turns the whole thing into a feedback loop between human judgment and AI output:

// "revise" branch β€” re-run AI with reviewer feedback
const revisionPrompt = `
  You previously wrote this article:
  ---
  Title: ${$json.title}

  ${$json.content}
  ---

  The reviewer asked for these changes:
  "${$json.notes}"

  Please revise accordingly. Keep the structure,
  just address the feedback.
`;

return {
  json: {
    prompt: revisionPrompt,
    originalTitle: $json.title,
    revisionRound: ($json.revisionRound || 0) + 1
  }
};

Putting it Together: AI Blog Post Pipeline

Here's a concrete production example β€” an automated Monday morning content pipeline that generates blog drafts and routes them through human review before publishing.

What this workflow does

  1. Schedule trigger fires every Monday at 8am
  2. AI node generates 3 blog drafts based on a Google Sheets content calendar
  3. For each draft: builds the HTML review form, deploys to customjs.space
  4. Posts to #content-team in Slack with three separate review links
  5. Workflow 1 ends β€” about 15 seconds total
  6. Reviewers open their links when they have time, edit as needed, click Submit
  7. Workflow 2 publishes approved drafts to CMS, sends revisions back to the AI node

The n8n community template from the top of this article is a solid starting point for exactly this setup, combined with the CustomJS n8n nodes for the hosting step.

Going Further

Multiple reviewers

Need more than one person involved? Generate a separate form URL for each reviewer and track who approved what. The Split In Batches node handles the iteration:

// One URL per reviewer, reviewer email embedded in form
const reviewers = ['[email protected]', '[email protected]'];

const reviewItems = reviewers.map(email => {
  const formHtml = buildReviewForm({
    content: aiDraft,
    reviewerEmail: email,
    webhookUrl: `${baseWebhook}?reviewer=${encodeURIComponent(email)}`
  });

  return { json: { html: formHtml, reviewer: email } };
});

return reviewItems; // Split In Batches handles the rest

Expiring links

Pass a ttl value when deploying and the page disappears automatically after that many seconds. Useful for time-sensitive content or compliance reasons:

{
  "html": "...",
  "options": {
    "ttl": 259200,
    "title": "Review due by Friday"
  }
}

Richer review interfaces

Because you're writing plain HTML, there's no constraint on what the review form can do. Teams have used this pattern to build side-by-side diff views, inline paragraph-level comment tools, brand voice sliders, even mini preview panes that render the content as it would look on the actual website. Any JavaScript library that runs in a browser is fair game β€” Quill for rich text editing, Diff2Html for comparisons, Chart.js if you're reviewing data outputs.

It's a bit of extra effort compared to a basic form, but for teams doing serious AI-assisted content work the payoff is real. Reviewers who can see exactly how a piece will render before approving tend to catch more issues and submit cleaner payloads.

Why This Works Well in Practice

The main thing people notice when they switch to this pattern is that review cycles actually complete. With the Wait node approach, executions die silently on restarts and nobody realises a draft is stuck. Here, if n8n goes down for maintenance the review page is still up and the webhook will catch the submission whenever it arrives.

The second thing is design flexibility. Once you're writing plain HTML you can match your company's exact visual style, add custom validation logic, pre-fill as many fields as you want, and handle edge cases that no generic form builder would anticipate. There's no "contact support to unlock this field type."

And practically speaking β€” you're not spinning up a Next.js app just to host a 60-line review form. customjs.space takes a POST request and gives you back a URL in milliseconds. The infrastructure overhead is basically zero.

No hanging executions

Both workflows are short-lived. State lives in the URL, not in n8n memory.

Pre-populated editing

Reviewers tweak AI output rather than retyping it β€” saves a lot of time.

Full audit trail

Each webhook payload includes who submitted, when, what they changed and why.

Any notification channel

Slack, Teams, Email, WhatsApp β€” send the URL wherever the reviewer actually is.

Related CustomJS Capabilities

The HITL pattern is just one piece. A few things that often come up in the same workflows:

Frequently Asked Questions

Can I still use the Wait node for quick approvals?

Yes, for approvals that happen within a few minutes β€” a manager sign-off on a small expense, say β€” the Wait node is perfectly fine. The 2-workflow setup pays off when reviews might take hours or days, or when you specifically need pre-filled editable forms.

How do I stop random people from submitting the form?

Easiest approach: embed a UUID token in the URL and store it as a hidden field. Anyone without the exact link can't do anything useful. For tighter security you can add a PIN shared via a separate Slack DM, or put HTTP Basic Auth on the receiving webhook.

What if the reviewer loses the link?

Store the page URL in Airtable, Notion or a Google Sheet as part of Workflow 1. If someone can't find their link they look it up in the sheet. You can also build a "resend" flow triggered by a Slack slash command β€” takes maybe 10 minutes to wire up.

Can this handle multi-stage review? Editor β†’ manager β†’ legal?

Yes. Each stage is its own Workflow 1 / Workflow 2 pair. When the editor approves, Workflow 2 generates a new form for the manager with all the edited content, and so on. You carry the full decision history in the webhook payload so each approver can see what changed before them.

How do I know which reviews are still pending?

Include a unique job ID in every form β€” generated in Workflow 1, embedded as a hidden field. When Workflow 2 fires, update a record in your database with status: 'reviewed'. A daily scheduled workflow can then flag anything pending for more than 48 hours and nudge the reviewer.

Does this work with self-hosted n8n?

Completely. The customjs.space API is a standard REST endpoint β€” it doesn't care whether n8n is on n8n.cloud or a VPS somewhere. You just need outbound internet access to api.customjs.space and a publicly reachable webhook URL for the form to POST to.

Final Thoughts

This pattern isn't magic, it's just a sensible separation of concerns. Workflow 1 generates and notifies, Workflow 2 handles the response. n8n builds the form, customjs.space hosts it, a second webhook catches the submission. The human gets a clean branded interface with everything pre-filled. The workflow gets structured input back.

If you want to skip the setup from scratch, the n8n community template shown above covers the core architecture and is a solid starting point. Pair it with a free customjs.space account β€” 600 page deployments per month, no credit card needed β€” and you can have something running the same afternoon.

Related Articles

Continue reading on similar topics