TL;DR
When doing an experiment where a network namespace receives traffic and does NAT on it, one can see that whatever the priority given to the type nat hook prerouting chain, it doesn't matter with regard to the filter chains priorities: NAT always happen at exactly prerouting hook priority -100 aka NF_IP_PRI_NAT_DST. Priority between NAT chains themselves is preserved.
You looked at the .hook entries in definitions which are for actual actions during packet traversal, but overlooked the .ops_register/.ops_unregister entries defined only for NAT hooks which introduce a different behavior when the chain is registered.
Tests done with kernel 6.5.x and nftables 1.0.9, some links provided on https://elixir.bootlin.com/ with latest LTS kernel at this date without patch revision: 6.1 (not 6.1.x).
To summarize:
NAT acts at special hook priorities, and only these priorities (rather than the priority given when adding the chain) are relevant when comparing with other hook types such as filter or route: NAT chains register differently than other chains. Still the given priorities apply internally between different NAT chains hooking at the same place.
route follows normal priorities just like filter (no special registration).
don't use exact priorities such as NF_IP_PRI_NAT_DST (or various other NAT-related exact values) elsewhere because then the precise interaction between how nftables and NAT hook into Netfilter might be undefined (example: could change depending on order of creation, or behavior could change depending on kernel version) instead of deterministic. For example use -101 or less to be before DNAT or -99 or more to be after DNAT but don't ever use -100 to avoid undefined behavior.
the same warning applies for other special facilities' priorities, described for example there, such as NF_IP_PRI_CONNTRACK_DEFRAG or NF_IP_PRI_CONNTRACK etc. (and for iptables priorities when also interacting with iptables rules and needing a deterministic outcome).
Experiment
I left aside cases such as family inet: one can just check it will behave the same with an adequate ruleset and test case.
Example ruleset (to be loaded using nft -f ...):
table t # for idempotence
delete table t # for idempotence
table t {
chain pf1 {
type filter hook prerouting priority -250; policy accept;
udp dport 5555 meta nftrace set 1 counter
}
chain pf2 {
type filter hook prerouting priority -101; policy accept;
udp dport 5555 counter accept
udp dport 6666 counter accept
}
chain pf3 {
type filter hook prerouting priority -99; policy accept;
udp dport 5555 counter accept
udp dport 6666 counter accept
}
chain pn1 {
type nat hook prerouting priority -160; policy accept;
counter
}
chain pn2 {
type nat hook prerouting priority 180; policy accept;
udp dport 5555 counter dnat to :6666
}
chain pn3 {
type nat hook prerouting priority -190; policy accept;
counter
}
chain pn4 {
type nat hook prerouting priority 190; policy accept;
udp dport 5555 counter dnat to :7777
udp dport 6666 counter dnat to :7777
}
}
This ruleset will change a received UDP port 5555 into port 6666 instead in pn2. pn1, pn3 and pn4 are here just for priority between NAT chains (pn4 also here to explain that NAT of a given type (DNAT, SNAT...) happens only once). There's a receiving application on UDP port 6666 (so the flow isn't deleted by an ICMP destination port unreachable), I used socat UDP4-LISTEN:6666,fork EXEC:date for this test and (interactively) sent two packets from a remote client using socat UDP4:192.0.2.2:5555 -.
One would believe that the NAT chain pn2 with priority 180 performing a DNAT would happen after filter chain pf3 with priority -99. But that's not what happens between type nat and other types: NAT is special. Using nft monitor trace like below:
# nft monitor trace
trace id 4ab9ba62 ip t pf1 packet: iif "lan0" ether saddr 8e:3e:82:1a:dc:87 ether daddr fa:2f:7e:2d:f1:03 ip saddr 192.0.2.1 ip daddr 192.0.2.2 ip dscp cs0 ip ecn not-ect ip ttl 64 ip id 49393 ip length 30 udp sport 58201 udp dport 5555 udp length 10 @th,64,16 0x610a
trace id 4ab9ba62 ip t pf1 rule udp dport 5555 meta nftrace set 1 counter packets 0 bytes 0 (verdict continue)
trace id 4ab9ba62 ip t pf1 verdict continue
trace id 4ab9ba62 ip t pf1 policy accept
trace id 4ab9ba62 ip t pf2 packet: iif "lan0" ether saddr 8e:3e:82:1a:dc:87 ether daddr fa:2f:7e:2d:f1:03 ip saddr 192.0.2.1 ip daddr 192.0.2.2 ip dscp cs0 ip ecn not-ect ip ttl 64 ip id 49393 ip length 30 udp sport 58201 udp dport 5555 udp length 10 @th,64,16 0x610a
trace id 4ab9ba62 ip t pf2 rule udp dport 5555 counter packets 0 bytes 0 accept (verdict accept)
trace id 4ab9ba62 ip t pn3 packet: iif "lan0" ether saddr 8e:3e:82:1a:dc:87 ether daddr fa:2f:7e:2d:f1:03 ip saddr 192.0.2.1 ip daddr 192.0.2.2 ip dscp cs0 ip ecn not-ect ip ttl 64 ip id 49393 ip length 30 udp sport 58201 udp dport 5555 udp length 10 @th,64,16 0x610a
trace id 4ab9ba62 ip t pn3 rule counter packets 0 bytes 0 (verdict continue)
trace id 4ab9ba62 ip t pn3 verdict continue
trace id 4ab9ba62 ip t pn3 policy accept
trace id 4ab9ba62 ip t pn1 packet: iif "lan0" ether saddr 8e:3e:82:1a:dc:87 ether daddr fa:2f:7e:2d:f1:03 ip saddr 192.0.2.1 ip daddr 192.0.2.2 ip dscp cs0 ip ecn not-ect ip ttl 64 ip id 49393 ip length 30 udp sport 58201 udp dport 5555 udp length 10 @th,64,16 0x610a
trace id 4ab9ba62 ip t pn1 rule counter packets 0 bytes 0 (verdict continue)
trace id 4ab9ba62 ip t pn1 verdict continue
trace id 4ab9ba62 ip t pn1 policy accept
trace id 4ab9ba62 ip t pn2 packet: iif "lan0" ether saddr 8e:3e:82:1a:dc:87 ether daddr fa:2f:7e:2d:f1:03 ip saddr 192.0.2.1 ip daddr 192.0.2.2 ip dscp cs0 ip ecn not-ect ip ttl 64 ip id 49393 ip length 30 udp sport 58201 udp dport 5555 udp length 10 @th,64,16 0x610a
trace id 4ab9ba62 ip t pn2 rule udp dport 5555 counter packets 0 bytes 0 dnat to :6666 (verdict accept)
trace id 4ab9ba62 ip t pf3 packet: iif "lan0" ether saddr 8e:3e:82:1a:dc:87 ether daddr fa:2f:7e:2d:f1:03 ip saddr 192.0.2.1 ip daddr 192.0.2.2 ip dscp cs0 ip ecn not-ect ip ttl 64 ip id 49393 ip length 30 udp sport 58201 udp dport 6666 udp length 10 @th,64,16 0x610a
trace id 4ab9ba62 ip t pf3 rule udp dport 6666 counter packets 0 bytes 0 accept (verdict accept)
trace id 46ad0497 ip t pf1 packet: iif "lan0" ether saddr 8e:3e:82:1a:dc:87 ether daddr fa:2f:7e:2d:f1:03 ip saddr 192.0.2.1 ip daddr 192.0.2.2 ip dscp cs0 ip ecn not-ect ip ttl 64 ip id 49394 ip length 30 udp sport 58201 udp dport 5555 udp length 10 @th,64,16 0x620a
trace id 46ad0497 ip t pf1 rule udp dport 5555 meta nftrace set 1 counter packets 0 bytes 0 (verdict continue)
trace id 46ad0497 ip t pf1 verdict continue
trace id 46ad0497 ip t pf1 policy accept
trace id 46ad0497 ip t pf2 packet: iif "lan0" ether saddr 8e:3e:82:1a:dc:87 ether daddr fa:2f:7e:2d:f1:03 ip saddr 192.0.2.1 ip daddr 192.0.2.2 ip dscp cs0 ip ecn not-ect ip ttl 64 ip id 49394 ip length 30 udp sport 58201 udp dport 5555 udp length 10 @th,64,16 0x620a
trace id 46ad0497 ip t pf2 rule udp dport 5555 counter packets 0 bytes 0 accept (verdict accept)
trace id 46ad0497 ip t pf3 packet: iif "lan0" ether saddr 8e:3e:82:1a:dc:87 ether daddr fa:2f:7e:2d:f1:03 ip saddr 192.0.2.1 ip daddr 192.0.2.2 ip dscp cs0 ip ecn not-ect ip ttl 64 ip id 49394 ip length 30 udp sport 58201 udp dport 6666 udp length 10 @th,64,16 0x620a
trace id 46ad0497 ip t pf3 rule udp dport 6666 counter packets 0 bytes 0 accept (verdict accept)
^C
one can see that all prerouting NAT hooks are happening between pf2 and pf3 ie between priorities -101 and -99: at priority -100 which is NF_IP_PRI_NAT_DST as used in Netfilter's own structures static const struct nf_hook_ops nf_nat_ipv4_ops[]. Chain ip t pf3 sees port 6666 and not 5555.
If a NAT statement has been applied, following rules (in the same hook) are skipped by Netfilter so pn4 never gets a chance here to be traversed at all in the example above (with only 2 packets of the same flow initially to port 5555) and never appears: this behavior also differs from type filter where the next hook is still traversed (eg: pf3 is still traversed after pf2).
As usual, the next packet in the flow doesn't trigger any NAT chain anymore since only packet creating a new flow (conntrack state NEW) are sent to NAT chains, so the next packet doesn't even display traversing pnX chains anymore. Priorities between the four prerouting NAT chains are honored: priority order is pn3 (-190) , pn1 (-160), pn2 (180) (and then there would be pn4 (190) but it doesn't get the chance).
Note: the fact that the packets/bytes counters don't appear increased in the same run of nft monitor trace looks like a bug or a missing feature to me (they are incremented when checking nft list ruleset).
type nat hooks use a different registering function than default for other nftables hooks so they can be handled differently:
.ops_register = nf_nat_ipv4_register_fn,
.ops_unregister = nf_nat_ipv4_unregister_fn,
It's to be handled by NAT (which is managed by Netfilter) and in hook NF_INET_PRE_ROUTING (still provided by Netfilter to nftables) this will be done at priority NF_IP_PRI_NAT_DST.
This is not done for type filter (nor route) which will then use a common nftables method rather than the specified one.