DEV Community

Cover image for Build a multi-step n8n Form with dynamic dropdowns (no plugin needed)
Hideki Mori
Hideki Mori

Posted on

Build a multi-step n8n Form with dynamic dropdowns (no plugin needed)

You want a multi-step form in n8n. Each step's dropdown depends on what the user picked or uploaded in the previous step. You search for "n8n dynamic dropdown" and find static fieldOptions examples and the occasional "use a Code node and hope."

I had the same problem building the distribution workflow for LDX hub, an AI document processing API. I wanted one workflow where a user picks one of five services, then walks through engine selection, file upload, and output format — with every dropdown computed from the previous step.

It turns out n8n already supports this. The feature is just hidden behind one toggle on the Form node.

This post walks through the pattern, with working code, three things to watch out for, and a link to a complete reference workflow you can import.


The one toggle that changes everything

The n8n Form node has a setting called Define Form. The default is Using Fields Below — you add fields in the UI like a typical form builder. There's a second option: Using JSON.

In JSON mode, the entire field array becomes an n8n expression. You return a JavaScript array where each element is a field definition, and you can reference any previous step's data inside that expression.

That's the whole technique. Everything below is just showing what you can do once you flip that switch.

The Define Form parameter switched to Using JSON, with the dropdown options visible

Here's the chain we're going to build, for one branch of the workflow:

ExtractDoc path: Form Trigger → HTTP Request → Form (Engine) → Set → Form (File) → Set → Form (Output) → Code → LDX hub Run → Form Ending

Form Trigger collects the API key and the service the user wants to use. The HTTP Request fetches the available engines from LDX hub. Then a sequence of Form / Set / Form / Set / Form steps progressively narrows the user's choices based on what came before. The LDX hub node runs the job. A final Form node displays the result.


The minimal example: dropdown from an API call

The simplest dynamic dropdown is: call an endpoint that returns a list, render that list as options.

LDX hub's /extractdoc/engines endpoint returns the available extraction engines without authentication. Calling it from n8n:

HTTP Request node
  URL: https://gw.ldxhub.io/extractdoc/engines
Enter fullscreen mode Exit fullscreen mode

The actual response right now:

{
  "data": [
    {
      "id": "ki/extract",
      "display_name": "KI Extract",
      "provider": "ki",
      "description": "Extracts plain text from documents in reading order...",
      "supported_conversions": [
        { "from": "pdf",  "to": "text"  },
        { "from": "pdf",  "to": "jsonl" },
        { "from": "docx", "to": "text"  },
        { "from": "docx", "to": "jsonl" },
        { "from": "xlsx", "to": "text"  },
        { "from": "xlsx", "to": "jsonl" },
        { "from": "pptx", "to": "text"  },
        { "from": "pptx", "to": "jsonl" }
      ]
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

One engine today. If the provider adds more tomorrow, they show up in the response automatically — and, as you're about to see, in the dropdown.

The Form node that follows the HTTP Request:

Form node
  Define Form: Using JSON
  JSON Output:
    {{ [
      {
        fieldLabel: "Engine",
        fieldName: "engine",
        fieldType: "dropdown",
        fieldOptions: {
          values: $json.data.map(e => ({ option: e.id }))
        },
        requiredField: true
      }
    ] }}
Enter fullscreen mode Exit fullscreen mode

The expression returns an array with one field. The field is a dropdown. Its options are computed by mapping the HTTP response's data array into the shape n8n expects ({ option: "ki/extract" }).

The JSON Output expression editor: the engine dropdown definition in the middle pane, the previous node's data tree on the left, and the result preview on the right

When the form renders, the user sees ki/extract as the only choice. The list reflects whatever the API returned just now, not what you hardcoded last week. That's the whole future-proofing argument for doing it this way.


Going one level deeper: filter by user's choice

Static dropdowns are easy. The interesting case is when one dropdown's options depend on what the user chose in a previous dropdown.

After the user picks an engine, I want to compute which input file formats it supports. That information is in the engine's supported_conversions array. A Set node does the derivation:

Set node
  Assignments:
    name:  from_options
    type:  array
    value: {{ $('ExtractDoc: Get Engines').item.json.data
              .find(e => e.id === $json.engine)
              .supported_conversions
              .map(c => c.from)
              .filter((v, i, a) => a.indexOf(v) === i) }}
Enter fullscreen mode Exit fullscreen mode

For ki/extract, this produces ["pdf", "docx", "xlsx", "pptx"].

Three things to notice in that expression:

  1. $('ExtractDoc: Get Engines').item.json — the long form for cross-step reference. The short form $json only sees the immediately previous step's data. To reach back past the Form node that ran in between, you need the long form. This is the single biggest gotcha. More on this below.

  2. $json.engine — the immediately previous step's data, which is the engine the user just selected.

  3. .filter((v, i, a) => a.indexOf(v) === i) — uniquify. Engines advertise the same input format across multiple conversion pairs (pdf → text, pdf → jsonl), and you don't want the dropdown to repeat "pdf" four times.

The next Form node renders the file upload field, restricting accepted file types to what the chosen engine actually supports:

Form node
  Define Form: Using JSON
  JSON Output:
    {{ [
      {
        fieldLabel: "Source File",
        fieldName: "file",
        fieldType: "file",
        acceptFileTypes: "." + $json.from_options.join(",."),
        requiredField: true
      }
    ] }}
Enter fullscreen mode Exit fullscreen mode

acceptFileTypes becomes the browser's <input accept="..."> attribute. With from_options = ["pdf", "docx", "xlsx", "pptx"], the resulting value is .pdf,.docx,.xlsx,.pptx — the user's file picker shows only those four types.


Even one more level: filter output by the uploaded file

The output format dropdown depends on what file the user actually uploaded — not what the engine generally supports, but what it can convert this specific input into.

This is where you reach into the uploaded file's metadata via $binary:

Set node
  Assignments:
    name:  to_options
    type:  array
    value: {{ (() => {
      const map = { jpg: 'jpeg', tif: 'tiff' };
      const raw = ($binary.file.fileExtension || '').toLowerCase();
      const from = map[raw] || raw;
      return $('ExtractDoc: Get Engines').item.json.data
        .find(e => e.id === $('ExtractDoc: Select Engine').item.json.engine)
        .supported_conversions
        .filter(c => c.from === from)
        .map(c => c.to)
        .filter((v, i, a) => a.indexOf(v) === i);
    })() }}
Enter fullscreen mode Exit fullscreen mode

A few notes on this expression:

  • IIFE wrapper (() => { ... })() — to use local variables (map, raw, from) and an explicit return. n8n expressions are JavaScript; any valid JS works inside {{ }}.

  • Extension mapping$binary.file.fileExtension returns whatever the user's file is named with. APIs sometimes use a canonical form (e.g., jpeg rather than jpg); a small map normalizes the boundary so the filter doesn't miss matches.

  • Two long-form references$('ExtractDoc: Get Engines') for the engine list, $('ExtractDoc: Select Engine') for the user's selection. Long form everywhere.

For a user uploading a PDF against ki/extract, this resolves to ["text", "jsonl"]. The output dropdown is then trivial:

Form node
  Define Form: Using JSON
  JSON Output:
    {{ [
      {
        fieldLabel: "Output Format",
        fieldName: "output_format",
        fieldType: "dropdown",
        fieldOptions: {
          values: $json.to_options.map(t => ({ option: t }))
        },
        requiredField: true
      }
    ] }}
Enter fullscreen mode Exit fullscreen mode

Here's what the user sees on the entry page of the rendered form, before any of the dynamic dropdowns appear:

The first page of the rendered form: API Key, API Host, and Service dropdown. The Engine, File, and Output Format dropdowns we just built appear on subsequent pages


Three things to watch out for

1. The short form $json does not reach across forms

$json refers to the immediately previous node's output. The moment a Form node sits between two nodes, $json can't see the data from before that Form.

Fix: the long form, $('Node Name').item.json.field. Works from anywhere in the workflow, regardless of how many nodes intervene.

Make it your default. Use the long form even when the short form would work — it'll save you when you later insert a Form node and don't want to rewrite three expressions.

2. Binary data needs an explicit pass-through

Form nodes return data on the json channel. The file the user uploaded sits on a separate binary channel that doesn't automatically propagate through Set nodes, Switch nodes, or subsequent Form nodes. To carry the binary forward to the node that actually consumes it, insert a Code node:

return $input.all().map(item => ({
  json: item.json,
  binary: $('ExtractDoc: Upload File').item.binary
}));
Enter fullscreen mode Exit fullscreen mode

This is the smallest Code node that does anything useful. Steal it.

3. When exporting as a template, blank out the webhookId fields

The Form Trigger and Form nodes each carry a webhookId UUID in the exported JSON. n8n preserves these on import — it doesn't regenerate them — so your IDs travel with the workflow into the importer's instance.

This is a known sharp edge in n8n's import path (issue #18683). The convention for shareable templates is to strip the IDs before publishing: set every webhookId value to "". The importing instance allocates fresh ones on first save.

If you forget, the most common failure mode is silent webhook hijacking — calls intended for your original workflow get routed to the imported one, or vice versa.

The reference workflow below ships with every webhookId blanked.


Why this matters

The default n8n template is a one-configuration workflow. You build it for your exact use case, save it, and it works for you. Anyone else who imports it either matches your configuration exactly or edits the JSON by hand.

Dynamic dropdowns change that. The same workflow now adapts to the user — their file, their choice of engine, their available output formats. The template becomes a small application instead of a personal artifact.

For LDX hub, this meant one workflow could expose five different AI services with all their valid configurations, without me hardcoding any of them. When the provider adds a sixth engine tomorrow, the workflow doesn't change. The Get Engines call returns one more entry, the dropdown shows one more option, the rest of the flow handles it.

That's the reason this technique is worth knowing. Not because dynamic dropdowns are interesting in themselves, but because they're the part of the n8n stack that lets you ship a configurable workflow.


The complete reference workflow

The full demo — five services, every dropdown driven by this pattern, plus dynamic credentials (a separate post) — ships with the n8n-nodes-ldxhub npm package as examples/all-services-demo.json. Import it into your n8n instance and inspect any of the five service paths to see the pattern in production form.

If you want to run it against LDX hub, get a free API key at gw.portal.ldxhub.io — 25,000 credits per month, no card. If you just want the pattern, the JSON is permissively licensed and the dropdown logic is portable to any API.


Closing

The dropdown pattern collapses what looks like a hard problem ("how do I make my n8n template work for arbitrary users") into something almost boring ("call an endpoint, render its response as options"). Most of the configurability complexity I've fought over the years dissolves the same way, once I find the right boundary to draw.

The interesting part wasn't writing the expressions. It was discovering that the toggle had been sitting there in the Form node settings the whole time. The technique looks novel only because it's underdocumented.

If you've been building n8n templates that only work for your exact setup, try moving one configuration knob to a dropdown driven by an API call. That's where this starts paying off.

Top comments (0)