
Kort fortalt
RFC 3986 afsnit 2.2 har [ og ] på listen over reserverede tegn, og samme afsnit skriver, at to URI'er, der kun adskiller sig ved at et reserveret tegn er procentkodet, ikke er ens. Google henviser til netop den standard — IETF STD 66 — på siden om URL-struktur, senest opdateret 10. december 2025. Den 20. oktober 2026 svarede John Mueller, at han havde antaget, at filter[]=123 og filter%5B%5D=123 var ens, og at de ikke er. Jeg kørte den samme filtertilstand gennem fem funktioner i Node 22.22, Python 3.13 og PHP 8.3 den 21. oktober 2026: fire af dem procentkoder, én lader parenteserne stå, og PHP's http_build_query() på et rigtigt array laver en tredje form med indekstal, filter%5B0%5D=123. PHP's parse_str() læser alle tre til det samme array, så serveren ser kun én tilstand. Og den oplagte udvej, at gentage nøglen i stedet, taber stille data i PHP: parse_str("filter=123&filter=456") returnerer én værdi, 456.
filter[]=123 og filter%5B%5D=123 er ikke den samme URL, og dubletten kommer ikke fra Google — den kommer fra din egen stak. Standarden gør de to former uens, og de biblioteker, der skriver dine links, dine canonicals og dit sitemap, er uenige om hvilken form de vælger. Udpeg ét lag i koden, der har retten til at serialisere URL'en, og lad alle fire kanaler læse derfra.
Hvad standarden faktisk siger
Googles side om URL-struktur, senest opdateret 10. december 2025, siger at Google understøtter URL'er "as defined by IETF STD 66". STD 66 er RFC 3986, og siden lister ikke selv de reserverede tegn — den linker til afsnit 2.2 og lader standarden svare.
Afsnit 2.2 har to lister. gen-delims er : / ? # [ ] @, og sub-delims er ! $ & ' ( ) * + , ; =. Begge firkantede parenteser står på den første. Og så står der én sætning, som afgør hele spørgsmålet: URI'er, der adskiller sig ved, at et reserveret tegn er erstattet med sin procentkodede oktet, er ikke ens.
Afsnit 3.2.2 strammer det yderligere. Firkantede parenteser må optræde ét sted i hele URI-syntaksen: omkring en IP-literal i værtsnavnet, altså en IPv6-adresse. Et filter[] i en query string er ikke en gyldig URI. Det er en framework-konvention, der er bygget ovenpå, og som serverne har valgt at forstå.
Den 20. oktober 2026 blev præcis det eksempel stillet op for John Mueller af Vanessa Fox: ?filter[]=123 over for ?filter%5B%5D=123. Hans svar var, at han havde antaget, at de var ens, men at de ikke er. Google crawler muligvis begge og de-duplikerer dem, hvis indholdet matcher. Hans råd var at vælge én form og holde den i links, canonicals, sitemaps og strukturerede data — og om et nyt site: "I wouldn't set up a new site like this." Han noterede også, at hex-case er uden betydning, så %5B og %5b er samme tegn (Search Engine Roundtable).
Målingen: fem funktioner, tre URL'er
Jeg kørte den samme filtertilstand gennem standardbiblioteket i tre runtimes den 21. oktober 2026. Ingen tredjepartspakker, ingen framework-lag — kun det, der følger med sproget.
Fire af de fem procentkoder. Den femte, new URL(u).href i Node, giver parenteserne tilbage præcis som de kom ind. Og PHP's http_build_query() opfører sig forskelligt afhængigt af, hvad du giver den: med en flad nøgle, ['filter[]' => '123'], får du filter%5B%5D=123, men med et rigtigt array, ['filter' => ['123','456']], får du filter%5B0%5D=123&filter%5B1%5D=456 — indekstal, som ikke var der i udgangspunktet.
Den mutation, der vender formen
Node-tilfældet er det ubehagelige, fordi det ikke er stabilt. new URL('https://eksempel.dk/k?filter[]=123').href giver parenteserne tilbage uændret. Det gør .toString() også. At læse en parameter med searchParams.get() ændrer heller ingenting.
Men enhver ændring af searchParams omskriver hele query stringen. Jeg kaldte searchParams.delete('zzz') på en nøgle, der slet ikke fandtes i URL'en, og fik ?filter%5B%5D=123 tilbage. Det samme sker med searchParams.append('side','2') — og det er præcis det mønster, enhver paginering, enhver sorteringsdropdown og enhver "tilføj et filter mere"-knap er bygget af.
Prøvede jeg den samme mutation på ?filter=123, kom URL'en uændret tilbage. Det er altså ikke mutationen, der er problemet. Det er det reserverede tegn, mutationen går hen over.
Konsekvensen er konkret: side 1 i et filtreret listevisning linkes med parenteser, fordi URL'en kom uberørt fra serveren. Side 2 linkes med %5B%5D, fordi klienten lagde en parameter til. Det er ikke to sider i den samme kæde. Det er to kæder.
Hvor de tre former mødes på samme site
Et filtreret kategorilag skriver typisk URL'en fire steder, og de fire steder bruger sjældent det samme lag kode:
- Links i HTML — serverrenderet fra en skabelon, der ofte skriver parenteserne råt.
- Canonical og sitemap — bygget med en query-builder, altså procentkodet.
- Klientside-navigation — paginering, "ryd filter", sortering:
searchParams, altså procentkodet, også når udgangspunktet var råt. - Intern søgning og facettællere — ofte et tredje bibliotek, og dermed en tredje mulighed.
Resultatet er ikke bare én dublet mere. Det er en canonical, der peger på en URL, intet link på sitet peger på; et sitemap, der indeholder en URL, brugeren aldrig lander på; og en pagineringskæde, der skifter form mellem side 1 og side 2.
Hvorfor ingen opdager det
Fordi serveren svarer 200 på alle tre. PHP's parse_str() læste filter%5B%5D=123&filter[]=456 — begge former i samme query string — til ét array med to værdier. Applikationen ser én tilstand, logfilen ser tre strenge, og det samme gør crawleren, cachen og rank trackeren.
Googles to egne sider er ikke enige om separatortegn
Google gjorde sin gamle vejledning om faceteret navigation til officiel dokumentation i december 2025, og siden er senest opdateret 18. december 2025. Den advarer om, at tegn som kommaer, semikolon og parenteser er svære for crawlere at genkende som separatorer, og peger på &.
Siden om URL-struktur, dateret otte dage tidligere, anbefaler kommaer, når én nøgle skal bære flere værdier. Det er ikke en modsigelse i teknisk forstand, men to sider, der blev opdateret i samme uge, giver to forskellige svar på samme spørgsmål — og den ene af dem fraråder det, den anden foreslår.
Den oplagte løsning virker i Node og Python og taber data i PHP
Den rene udvej ser ud til at være at droppe parenteserne og gentage nøglen: ?filter=123&filter=456. Kun = og &, ingen reserverede tegn at kode eller ikke kode, og begge Google-sider er enige om de to tegn. Node og Python håndterer det nativt — searchParams.getAll() og parse_qs() giver begge værdier tilbage.
PHP gør ikke. parse_str("filter=123&filter=456") returnerede filter = "456". Én værdi. Den første blev tabt uden en advarsel. Og http_build_query() kan ikke producere gentagne nøgler fra et array i første omgang — den indsætter indekstal. På en PHP-stak er gentagne nøgler altså ikke en mulighed, med mindre du parser $_SERVER['QUERY_STRING'] selv.
Derfor er anbefalingen på en PHP-stak den kedelige: commit til %5B%5D overalt. Ikke fordi den form er pænere, men fordi den er, hvad fire af de fem funktioner skriver af sig selv. Vælger du den rå form, skal du slå hver enkelt mutation i din egen frontend — for altid.
Rækkefølgen, hvis du skal rydde op
- Grep kodebasen for
%5B, for[]=og forsearchParams. Listen fortæller dig, hvor mange lag der i dag skriver URL'en. - Vælg én form og ét lag, der ejer serialiseringen. Alle andre lag kalder derind.
- Få links, canonical, sitemap og klientside-navigationen til at skrive den samme streng. Det er her gevinsten ligger — ikke i redirectet.
- Slå begge former op hver for sig i URL-inspektionen. Værktøjet tager en eksakt URL, og det er netop pointen, at de to er forskellige URL'er.
- Først derefter en 301 fra den gamle form til den valgte. Et redirect, mens dine egne links stadig peger på begge, flytter bare problemet ind i crawl-logfilen.
Det er ikke standarden, der er for streng
Mueller lukkede tråden med, at der ikke var noget i vejen — internetstandarderne for URL'er er bare meget præcise. Det er den rigtige diagnose. RFC 3986 har været uændret på netop dette punkt siden januar 2005, og den siger tydeligt, hvor en firkantet parentes må stå. Det, der er nyt, er antallet af lag i en moderne frontend, som hver især serialiserer den samme filtertilstand én gang mere, uden at nogen har besluttet hvem der skrev den rigtigt.
Så det her er ikke en Google-opdatering, du skal reagere på. Det er et grep efter %5B i din egen kodebase, og et svar på, hvilket lag der ejer URL'en. Har du ikke udpeget det lag, ejer tre af dem den.