n8n Declarative Node: Resources, Operations and Routing
In this video
This is the written version of Part 3 of Build your own n8n node. Your API has endpoints: POST /contacts, GET /contacts/{id}, PATCH /deals/{id}. The person using your node thinks "I want to add a contact." Those two don't line up, and closing that gap is most of what building a node is.
By the end of this part the node has a Resource dropdown, an Operation dropdown and an Email field, and it sends real requests. We get there without writing a single function, because in the declarative style you describe a request and n8n sends it. Then we press Execute for the first time, and it fails.
Resources and operations
n8n asks two questions in almost every node: which thing, and what to do to it.
- The thing is a resource: a contact, a deal, an invoice. The nouns in your product.
- The action is an operation: Create, Get, Get Many, Update, Delete.
Look at your endpoints that way and they sort themselves into a grid:
| Create | Get | Get Many | Update | Delete | |
|---|---|---|---|---|---|
| Contact | POST /contacts |
GET /contacts/{id} |
GET /contacts |
PATCH /contacts/{id} |
DELETE /contacts/{id} |
| Deal | POST /deals |
GET /deals/{id} |
GET /deals |
PATCH /deals/{id} |
DELETE /deals/{id} |
Every endpoint is some action on some thing.
The base of the node
All of this lives in nodes/AcmeCrm/AcmeCrm.node.ts. The top of the description says what the node is and where its requests go:
import { NodeConnectionTypes, type INodeType, type INodeTypeDescription } from 'n8n-workflow';
export class AcmeCrm implements INodeType {
description: INodeTypeDescription = {
displayName: 'Acme CRM',
name: 'acmeCrm',
icon: 'file:acme.svg',
group: ['transform'],
version: 1,
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
description: 'Work with contacts and deals in Acme CRM',
defaults: { name: 'Acme CRM' },
inputs: [NodeConnectionTypes.Main],
outputs: [NodeConnectionTypes.Main],
requestDefaults: {
baseURL: 'https://api.acme.example/v1',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
},
properties: [
// the dropdowns and fields go here
],
};
}
requestDefaults is the part every request shares, so each operation only has to say what's different about it.
The Resource dropdown
Everything a user sees in a node is one of these small blocks: a name to show, a name for the code, a type. The resource is a dropdown with two choices:
{
displayName: 'Resource',
name: 'resource',
type: 'options',
noDataExpression: true,
options: [
{ name: 'Contact', value: 'contact' },
{ name: 'Deal', value: 'deal' },
],
default: 'contact',
},
Save it, and the node has a dropdown. About ten lines in, it already looks like a real node.
Operations, with no function
Now the operations for contacts. Look at the routing part:
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: { show: { resource: ['contact'] } },
options: [
{
name: 'Create',
value: 'create',
action: 'Create contact',
description: 'Create a new contact',
routing: { request: { method: 'POST', url: '/contacts' } },
},
{
name: 'Get',
value: 'get',
action: 'Get contact',
description: 'Retrieve a contact',
routing: { request: { method: 'GET', url: '=/contacts/{{$parameter.contactId}}' } },
},
],
default: 'create',
},
There's no fetch, no response handling, no function. "Create means POST to /contacts." That's a description of a request, and n8n makes it for you. That's the reason to start declarative: there's less that can break.
Two details:
- A URL that starts with
=is an expression.{{$parameter.contactId}}puts the value of the Contact ID field into the path. nameis what shows in the dropdown, in Title Case, without repeating the resource.actionis what shows in the node search, in sentence case, with the resource and without articles: "Create contact", not "Create a contact". Those are n8n's UX rules, and reviewers check them.
displayOptions: show only what belongs
Without displayOptions, every operation and field shows all the time. A user picks Contact and still sees "Move deal to stage". It's confusing, and it looks broken.
displayOptions says when something appears:
displayOptions: { show: { resource: ['contact'], operation: ['create'] } },
That's "only show this when the user picked Contact, then Create." You'll put it on nearly every field you write, and the node starts to feel like it's paying attention to what was chosen.
A field that sends itself
A contact needs an email:
{
displayName: 'Email',
name: 'email',
type: 'string',
placeholder: 'e.g. nathan@example.com',
required: true,
default: '',
displayOptions: { show: { resource: ['contact'], operation: ['create'] } },
routing: { send: { type: 'body', property: 'email' } },
},
Same idea as the operation. You don't build a request body anywhere. You say "this field goes in the body, under email," and n8n puts it there. type can also be query for a query-string parameter.
Pick Contact, then Create, and an Email box appears. Whatever someone types goes to the right place in the request.
The repetitive part
Now the honest bit. You do that again for Get. And Get Many. And Update and Delete. Then the whole set again for deals.
It's the same shape every time, and there's a lot of it. Two resources and ten operations already make a file of a few hundred lines, and your real API probably has more than two resources. Because so much of it is copy and paste, it's easy to leave one small mistake in the middle: a field with the wrong property name that never gets sent, and nothing complains.
Two habits help:
- Split the file. Put each resource's operations and fields in their own file, like
ContactDescription.ts, and spread them intoproperties. n8n's own nodes do this. - Check each operation in n8n as you add it, not all of them at the end.
The first real try
Everything's wired up. In n8n: Contact, Create, type an email, press Execute step.
401 Unauthorized.
It's not a bug in anything we wrote. The node knows exactly what to send now. Your API has no idea who's sending it, because we never told the node how to log in.
What you have now
- an API modelled as resources and operations
- operations wired to requests with no code, through
routing - fields that appear only when they belong, through
displayOptions - fields that put their value in the body or the query string by themselves
Next, in Part 4, that 401 goes away. The node logs in and creates a contact in a real account, and a wrong key gets caught in the form before it breaks a workflow.
One more thing. The repetitive part, the same block for every operation, is what NativeShip, the tool we build, generates from an OpenAPI spec. It's still worth doing by hand once, so you know what a good one looks like.
Frequently asked questions
A resource is a thing in your product, like a contact or a deal. An operation is what to do with it, like Create, Get, Update or Delete. Almost every n8n node is built as these two dropdowns.
routing describes the HTTP request an operation or field makes: the method, the URL, and where a field's value goes. n8n reads it and sends the request, so you don't write an execute() function.
It controls when a field or operation is shown. displayOptions: { show: { resource: ['contact'] } } shows an element only when the user picked Contact, so people only see options that belong to their choice.
The API doesn't know who is calling, because the node isn't sending any credentials yet. Part 4 adds a credential that puts the API key on every request.