n8n-nodes-rawform
v0.1.4
Published
n8n community nodes that render fully author-controlled, unsanitised HTML as multi-page web forms, driven by n8n's durable wait/resume.
Maintainers
Readme
n8n-nodes-rawform
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 stripsform,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-rawformor from a shell on the n8n host / image:
npm i n8n-nodes-rawformRestart 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
GETserves page 1; itsPOSTstarts the execution. - Each Next Page Response node parks the execution (
putExecutionToWait) and, on resume, itsGETrenders that page and itsPOSTresumes the run with the submitted data. - The Ending Response node renders a final page (or a redirect, or a binary
download). Its resume
GETboth 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": "[email protected]",
"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 withsubmittedAt/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). submittedAtis 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
httpBasicAuthcredential; the browser gets a401+WWW-Authenticateuntil 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-downloadsThis 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 testinvokes bun's own test runner and reports spurious failures — the unit suite runs under vitest viabun 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
