rawform

n8n community nodes that render fully author-controlled, unsanitised HTML as multi-page web forms, driven by n8n's durable wait/resume.

Package Information

Released: 1 mo ago
Downloads: 0 weekly / 0 monthly
Latest Version: 0.1.4
Author: Rad Orlowski

Documentation

n8n-nodes-rawform

CI
npm

Two n8n community nodes — Raw Form Trigger and Raw Form Response — that
serve fully author-controlled, unsanitised HTML as multi-page web forms,
driven by n8n's native durable wait/resume so a workflow can park on a form page
for seconds or days.

They replicate the mechanism of stock n8n's Form Trigger / Form nodes.
The one product difference:

The page markup is a raw HTML string you supply — your own <head>,
<style>, <script>, arbitrary <form>/<input>, any framework (SurveyJS,
a React bundle, Tailwind via CDN, …). Stock n8n runs custom form HTML through
an allow-list that strips form, input, button, select, textarea,
script, style, head, html, body, so custom HTML there is
decoration-only. Here nothing is stripped.


⚠️ Do not install on a multi-tenant n8n

Installing a node that executes workflow-author-supplied HTML/JS is only safe
when every workflow author is fully trusted.

Every served page runs under a null-origin sandbox CSP (below). That stops
the page abusing the viewer's n8n session — it does not stop the author's
JavaScript making arbitrary outbound fetches: exfiltrating submitted form
data to a third party, or beaconing / port-scanning from the n8n host's network
position (significant if n8n sits inside a corporate perimeter).

Do not install n8n-nodes-rawform on a multi-tenant n8n where workflow authors
are not fully trusted.
On a single-tenant / personal / trusted-team instance it
is no more dangerous than a Code node.


Install

Community Nodes UI (n8n Settings → Community Nodes → Install):

n8n-nodes-rawform

or from a shell on the n8n host / image:

npm i n8n-nodes-rawform

Restart n8n. Two nodes appear: Raw Form Trigger (a trigger) and Raw Form
Response
(an action, "Next Page" or "Ending").

Supported n8n

2.36.x (SUPPORTED_N8N_RANGE in
nodes/shared/version.ts).

The GET-render step of the wait/resume mechanism rests on an internal n8n
implementation detail
(WebhookHelpers.executeWebhook()'s
early-return-when-no-workflowData). It is not covered by any n8n public API
contract, so the supported range is verified per-version by our E2E suite
(e2e/) and widened only after a green run on the new version — never on
a version number alone. If you run a newer n8n and it works, open an issue with
your version and we'll add it to the matrix.


How a flow is built

Raw Form Trigger ──▶ (your workflow) ──▶ Raw Form Response (Next Page) ──▶ … ──▶ Raw Form Response (Ending)
   GET  renders page 1                      parks, GET renders the next page          renders the final page
   POST starts the run                      POST resumes the run                      and completes the run
  • The Trigger GET serves page 1; its POST starts the execution.
  • Each Next Page Response node parks the execution (putExecutionToWait) and,
    on resume, its GET renders that page and its POST resumes the run with the
    submitted data.
  • The Ending Response node renders a final page (or a redirect, or a binary
    download). Its resume GET both shows the page and completes the execution,
    so any nodes wired after it run — this works on the poll path and even if the
    visitor reopens the link days later.

Page transitions use a 303 redirect to the signed resume URL, sent in
milliseconds before the wait — the browser then polls that URL (200 = next
page ready, 409 = still running) until the flow advances. Nothing depends on a
held HTTP connection surviving across the wait, so a reverse proxy with a short
idle timeout in front of n8n is fine.

Output shape

Both nodes output one item per submission:

{
  "json": {
    "data": {                     // ← your fields live under json.data (not flat)
      "email": "ada@example.com",
      "topics": ["ml", "hardware"] // repeated name= → array
    },
    "submittedAt": "2026-08-30T14:12:03.000+01:00", // workflow timezone by default
    "formMode": "production"       // or "test" for a manual run
  },
  "binary": {                      // present only if the form uploaded files
    "resume": { /* n8n binary data */ }
  }
}
  • Fields are nested under json.data (deviation from stock's flat shape — it
    avoids collisions with submittedAt / formMode).
  • A repeated name= becomes an array; a single one stays a string.
  • File <input type="file"> values become n8n binary properties on the
    item (named after the field; suffixed _2, _3, … for multiples).
  • submittedAt is ISO-8601 in the workflow timezone unless you turn off
    Use Workflow Timezone on the trigger (then UTC).

Auth

On the Trigger, Authentication:

  • None (default) — the webhook is as exposed as any n8n webhook.
  • Basic Auth — an httpBasicAuth credential; the browser gets a 401 +
    WWW-Authenticate until it sends valid credentials.

There is no n8n User Auth option — n8n's form-user OAuth path is not
reachable from a community node. Put an authenticating proxy in front if you need
SSO.

Sandbox CSP (always on, no opt-out)

Every page is served with:

Content-Security-Policy: sandbox allow-scripts allow-forms allow-modals allow-popups allow-downloads

This is a null origin: no cookies, and the page's JS cannot reach n8n's
authenticated API as the viewer. There is deliberately no node-level opt-out
(stock has N8N_INSECURE_DISABLE_FORM_HTML_SANDBOX at the instance level if an
operator truly needs same-origin). Your HTML is not otherwise sanitised —
your form is responsible for its own XSS posture within its own null origin.

⚠️ Client-side {{ }} template engines are not supported in v1

Your Page HTML / Ending HTML is always run through n8n's expression
evaluator
first — that is how {{ $json.data.name }} works. n8n resolves
every {{ … }} in the document, so a page built with a template engine that
also uses {{ }} — Vue, Angular, Handlebars, Mustache — is mangled or throws
before it ever reaches the browser.

Use one of:

  • %%…%% placeholder substitution for the values the node injects
    (%%N8N_SUBMIT_URL%%, %%N8N_TEST_MODE%%) — these are replaced after the
    expression pass and never collide with {{ }}.
  • A JS-config framework that does not use {{ }} in markup — e.g. SurveyJS
    (schema is a JS object), a hand-written React bundle using JSX / h(), htmx,
    Alpine (x-text), etc.

There is deliberately no "don't evaluate expressions" toggle in v1.

Recommend: set Limit Wait Time on public / high-volume forms

With Limit Wait Time off, an abandoned form parks forever and the
execution row stays waiting indefinitely (same as the stock Wait node). On a
public or high-volume form that piles up.

Turn Limit Wait Time on (Next Page node) with a Max Wait — at the cap n8n
disables the parked node and resumes the workflow, passing the previous page's
item through the node's single output unchanged. Detect the abandoned journey
downstream
(e.g. an IF on submittedAt / a marker field) — there is no
separate "timed out" output.


Worked examples

Each block is a complete workflow you can paste into n8n
(Workflows → ⋯ → Import from File won't take inline JSON; use
Import from URL or just recreate the three nodes). Node parameters are shown;
wire them Trigger → Response(s) on the main connector.

(a) Plain two-page form

Page 1 collects a name, page 2 confirms, an ending thanks the visitor.

Raw Form Trigger — Page HTML:

<!doctype html><meta charset="utf-8"><title>Step 1</title>
<h1>Your details</h1>
<form>
  <label>Name <input name="name" required></label>
  <label>Email <input name="email" type="email" required></label>
  <button type="submit">Next</button>
</form>

Raw Form Response — Page Type: Next Page, Page HTML:

<!doctype html><meta charset="utf-8"><title>Step 2</title>
<h1>Anything to add?</h1>
<form>
  <label>Notes <textarea name="notes"></textarea></label>
  <button type="submit">Finish</button>
</form>

Raw Form Response — Page Type: Ending, Respond With: Custom HTML,
Ending HTML:

<!doctype html><meta charset="utf-8"><title>Thanks</title>
<h1>Thanks, {{ $json.data.name }}</h1>
<p>We'll be in touch at {{ $json.data.email }}.</p>

A single <form> with no n8nRawForm reference is auto-wired: the runtime
sets method/action, intercepts submit, and drives the poll for you.

(b) SurveyJS page — manual submit via window.n8nRawForm.submit

When your page has no plain <form> (a JS widget owns the UI), call the
injected API yourself. The runtime always exposes:

window.n8nRawForm.submit(formElementOrFormDataOrPlainObject) // → Promise<void>

Raw Form Response — Next Page, Page HTML:

<!doctype html><meta charset="utf-8"><title>Survey</title>
<link href="https://unpkg.com/survey-core/survey-core.min.css" rel="stylesheet">
<div id="survey"></div>
<script src="https://unpkg.com/survey-core/survey.core.min.js"></script>
<script src="https://unpkg.com/survey-js-ui/survey-js-ui.min.js"></script>
<script>
  const survey = new Survey.Model({
    elements: [
      { type: 'rating', name: 'nps', title: 'How likely are you to recommend us?' },
      { type: 'comment', name: 'why', title: 'Why?' }
    ]
  })
  survey.onComplete.add((s) => {
    // hand the flat answers straight to n8n; resolves once the run has resumed
    window.n8nRawForm.submit(s.data)
  })
  document.addEventListener('DOMContentLoaded', () =>
    survey.render(document.getElementById('survey')))
</script>

The answers arrive as {{ $json.data.nps }}, {{ $json.data.why }}.
window.n8nRawForm.testMode is true under a manual/test execution if you want
to branch on it. External CDN scripts require the sandbox's allow-scripts
(always on) and network egress from the browser.

(c) Branching with a Switch

Route to a different page 3 based on a page-2 answer.

Trigger ─▶ Response "Page 2" ─▶ Switch ─▶ Response "Page 3 — team"
                                      └──▶ Response "Page 3 — solo"
                                                     both ─▶ Response "Ending"

Switch (n8n core) — Mode: Expression, Number of Outputs: 2, output:

={{ $json.data.plan === "team" ? 0 : 1 }}

Each downstream Raw Form Response ("Next Page") renders its own HTML and
waits; both feed one Ending node. The resume URL is the same for every hop of
a given execution, so branching needs nothing special — just wire the graph.

(d) Days-long park with an emailed resume link

Send the visitor away and let them come back via a link in their inbox. The
signed resume URL is {{ $execution.resumeUrl }} — valid for the life of the
parked execution (turn on Limit Wait Time to bound it).

Trigger ─▶ Send Email ─▶ Raw Form Response "Continue later" (Next Page)

Send Email (n8n core) — Text:

Hi {{ $json.data.name }},

Pick up where you left off any time in the next 7 days:
{{ $execution.resumeUrl }}

Raw Form Response — Next Page, Limit Wait Time: on, Max Wait:
{{ $now.plus(7, 'days') }}, Page HTML: a simple "we've emailed you a link to
continue" holding page. When they click the link days later, this node's GET
renders the real next page and the flow resumes exactly where it parked — through
an n8n restart, too.


Development

This repo uses bun.

bun install
bun run typecheck
bun run test                  # vitest unit suite — use `bun run test`, not `bun test`
bun run build                 # tsc + copy the icon svgs → dist/
bun run test:e2e              # 1 dockerised n8n, regular/production, all scenarios
bun run test:e2e -- --matrix  # full grid: {regular,queue} × {production,manual}

bun test invokes bun's own test runner and reports spurious failures — the
unit suite runs under vitest via bun run test.

The E2E harness (e2e/README.md) is the load-bearing check —
it stands up a real dockerised n8n, loads the built dist/ as a custom
extension, and drives the whole wait/resume mechanism over real HTTP, because
that mechanism rests on n8n internals no unit test can reach. It needs Docker
and the pinned image (docker pull docker.n8n.io/n8nio/n8n:2.36.8).

Licence

MIT

Discussion