Query String Encoding of a JavaScript Object — Build Params Like a Pro

Every frontend developer eventually needs to convert a JavaScript object into URL query parameters.

The naive approach looks like this:

const params = {
  q: "javascript tutorial",
  page: 1,
  limit: 20
};

const url = `https://api.example.com/search?q=${params.q}&page=${params.page}&limit=${params.limit}`;

This works until it doesn't. Add a special character, a nested value, or an array, and the entire query string breaks.

This guide covers how to encode JavaScript objects into query parameters safely, handle nested structures, and avoid the common pitfalls that cause production bugs.


The Wrong Way: Manual String Concatenation

const params = {
  q: "javascript & node.js",
  category: "programming"
};

const url = `https://api.example.com/search?q=${params.q}&category=${params.category}`;

Output:

https://api.example.com/search?q=javascript & node.js&category=programming

This URL is broken. The & inside the query value is interpreted as a parameter separator. The server receives:

q=javascript
node.js=        ← empty value
category=programming

Manual concatenation is fragile, unsafe, and should never be used with user-generated values.


The Right Way: URLSearchParams

Modern JavaScript provides URLSearchParams for safe query string construction.

const params = {
  q: "javascript & node.js",
  category: "programming"
};

const searchParams = new URLSearchParams(params);
const url = `https://api.example.com/search?${searchParams.toString()}`;

console.log(url);

Output:

https://api.example.com/search?q=javascript+%26+node.js&category=programming

URLSearchParams automatically encodes:

  • Spaces as +
  • & as %26
  • Special characters in general

Using the URL Constructor

The URL class combined with URLSearchParams is even cleaner:

const url = new URL("https://api.example.com/search");

url.searchParams.set("q", "javascript & node.js");
url.searchParams.set("category", "programming");

console.log(url.toString());

Output:

https://api.example.com/search?q=javascript+%26+node.js&category=programming

This is the safest approach. The URL object manages everything — base URL, search params, encoding — automatically.


Handling Different Value Types

Strings

url.searchParams.set("q", "hello world");
// Result: q=hello+world

Numbers

url.searchParams.set("page", 1);
url.searchParams.set("limit", 20);
// Result: page=1&limit=20

Booleans

url.searchParams.set("active", true);
// Result: active=true

URLSearchParams converts all values to strings automatically.


Handling Arrays

URLSearchParams does not have native array support. If you pass an array as a value, it calls .toString() on it, which joins with commas:

const params = new URLSearchParams({ tags: ["js", "node", "react"] });
console.log(params.toString());

Output:

tags=js,node,react

This may or may not be what your API expects.

Common Array Serialization Patterns

Repeated Keys (Most Common)

const tags = ["js", "node", "react"];
const params = new URLSearchParams();

tags.forEach(tag => params.append("tag", tag));

console.log(params.toString());

Output:

tag=js&tag=node&tag=react

Many APIs (and frameworks like Express.js, Django, Rails) parse repeated keys into arrays automatically.

Bracket Notation (PHP Style)

const tags = ["js", "node", "react"];
const params = new URLSearchParams();

tags.forEach(tag => params.append("tags[]", tag));

console.log(params.toString());

Output:

tags[]=js&tags[]=node&tags[]=react

PHP-style bracket notation is less common outside PHP but some APIs use it.

Comma-Separated

const tags = ["js", "node", "react"];
url.searchParams.set("tags", tags.join(","));

// Result: tags=js,node,react

Some APIs prefer comma-separated values in a single parameter.


Handling Nested Objects

URLSearchParams does not handle nested objects natively. You need a custom serializer.

Dot Notation Serialization

function serialize(obj, prefix = "") {
  const params = new URLSearchParams();

  for (const [key, value] of Object.entries(obj)) {
    const fullKey = prefix ? `${prefix}.${key}` : key;

    if (typeof value === "object" && value !== null && !Array.isArray(value)) {
      // Recursively serialize nested objects
      const nested = serialize(value, fullKey);
      nested.forEach((v, k) => params.append(k, v));
    } else {
      params.append(fullKey, value);
    }
  }

  return params;
}

// Usage
const filters = {
  price: { min: 10, max: 100 },
  category: "electronics"
};

const params = serialize(filters);
console.log(params.toString());

Output:

price.min=10&price.max=100&category=electronics

Square Bracket Serialization (Rails/Express Style)

function serializeRails(obj, prefix = "") {
  const params = new URLSearchParams();

  for (const [key, value] of Object.entries(obj)) {
    const fullKey = prefix ? `${prefix}[${key}]` : key;

    if (typeof value === "object" && value !== null && !Array.isArray(value)) {
      const nested = serializeRails(value, fullKey);
      nested.forEach((v, k) => params.append(k, v));
    } else {
      params.append(fullKey, value);
    }
  }

  return params;
}

// Usage
const filters = {
  price: { min: 10, max: 100 }
};

const params = serializeRails(filters);
console.log(params.toString());

Output:

price[min]=10&price[max]=100

Building a Complete URL from an Object

Here is a reusable utility that handles the common patterns:

class QueryBuilder {
  constructor(baseUrl) {
    this.url = new URL(baseUrl);
  }

  addParam(key, value) {
    if (value === undefined || value === null) return this;

    if (Array.isArray(value)) {
      value.forEach(v => this.url.searchParams.append(key, v));
    } else if (typeof value === "object") {
      Object.entries(value).forEach(([k, v]) => {
        this.url.searchParams.append(`${key}[${k}]`, v);
      });
    } else {
      this.url.searchParams.set(key, value);
    }

    return this;
  }

  addParams(obj) {
    Object.entries(obj).forEach(([key, value]) => this.addParam(key, value));
    return this;
  }

  build() {
    return this.url.toString();
  }
}

// Usage
const url = new QueryBuilder("https://api.example.com/search")
  .addParams({
    q: "javascript",
    tags: ["js", "node"],
    page: 1,
    filters: { price: 100 }
  })
  .build();

console.log(url);

Encoding Existing Objects

If you receive an encoded query string and need to modify it:

const existingUrl = "https://example.com/search?q=hello+world&page=1";

const url = new URL(existingUrl);
url.searchParams.set("q", "new search");
url.searchParams.delete("page");

console.log(url.toString());
// https://example.com/search?q=new+search

Dealing with + vs %20

URLSearchParams uses + for spaces. If your API expects %20, you need a different approach.

// URLSearchParams produces +
const params = new URLSearchParams({ q: "hello world" });
console.log(params.toString());  // q=hello+world

// Use encodeURIComponent for %20
const q = encodeURIComponent("hello world");
const url = `https://example.com/search?q=${q}`;
console.log(url);  // https://example.com/search?q=hello%20world

Uncommon but Useful Patterns

Removing Undefined or Null Values

const raw = { q: "test", page: null, limit: undefined, sort: "relevance" };
const cleaned = Object.fromEntries(
  Object.entries(raw).filter(([_, v]) => v != null)
);

const params = new URLSearchParams(cleaned);
console.log(params.toString());  // q=test&sort=relevance

Encoding Dates

const params = {
  from: new Date("2026-01-01").toISOString(),
  to: new Date("2026-12-31").toISOString()
};

const url = new URL("https://api.example.com/analytics");
url.searchParams.set("from", params.from);
url.searchParams.set("to", params.to);

console.log(url.toString());
// https://api.example.com/analytics?from=2026-01-01T00%3A00%3A00.000Z&to=2026-12-31T00%3A00%3A00.000Z

Testing Query String Construction

describe("QueryBuilder", () => {
  it("encodes simple params", () => {
    const url = new URL("https://example.com/search");
    url.searchParams.set("q", "hello world");
    expect(url.toString()).toContain("q=hello+world");
  });

  it("encodes special characters", () => {
    const url = new URL("https://example.com/search");
    url.searchParams.set("q", "javascript & node.js");
    expect(url.toString()).toContain("%26");
  });

  it("handles arrays", () => {
    const url = new URL("https://example.com/search");
    ["a", "b"].forEach(v => url.searchParams.append("tag", v));
    expect(url.toString()).toContain("tag=a&tag=b");
  });

  it("handles empty values", () => {
    const url = new URL("https://example.com/search");
    url.searchParams.set("q", "");
    expect(url.toString()).toContain("q=");
  });

  it("round-trips correctly", () => {
    const original = "javascript & node.js";
    const url = new URL("https://example.com/search");
    url.searchParams.set("q", original);
    const decoded = url.searchParams.get("q");
    expect(decoded).toBe(original);
  });
});

Best Practices

Always Use URLSearchParams or URL Constructor

// Good
new URLSearchParams({ key: value });

// Bad
`?key=${value}`

Handle Arrays Explicitly

// Know what format your API expects
url.searchParams.append("tags", tag);     // repeated keys
url.searchParams.set("tags", tags.join(","));  // comma-separated

Validate Before Building

function buildSafeUrl(base, params) {
  const url = new URL(base);

  Object.entries(params).forEach(([key, value]) => {
    if (value !== undefined && value !== null) {
      url.searchParams.set(key, value);
    }
  });

  return url.toString();
}

Consider Third-Party Libraries for Complex Cases

For deeply nested objects or complex serialization, consider:

  • qs — the most popular query string library (supports nesting, arrays, custom formats)
  • query-string — a lighter alternative
import qs from "qs";

const params = qs.stringify({
  filter: { price: { min: 10, max: 100 } },
  tags: ["js", "node"]
});

console.log(params);
// filter[price][min]=10&filter[price][max]=100&tags[0]=js&tags[1]=node

Related Resources

For more on encoding techniques and cross-language patterns:


FAQ

What is the safest way to build a query string in JavaScript?

Use the URL constructor with searchParams. It handles encoding automatically and reduces human error.

Does URLSearchParams handle nested objects?

No. You need a custom serializer or a library like qs for nested object serialization.

How do I handle arrays in query parameters?

Use append() for repeated keys (?tag=a&tag=b) or set() with a comma-separated value.

Why does URLSearchParams use + for spaces?

Because URLSearchParams follows the application/x-www-form-urlencoded standard, which uses + for spaces.

How do I use %20 instead of + with URLSearchParams?

You cannot change URLSearchParams behavior. Use encodeURIComponent() manually if you need %20.

What is the difference between URLSearchParams and qs?

URLSearchParams is a native browser API with basic features. qs is a third-party library that supports nesting, arrays with indices, and custom formats.

How do I remove null or undefined values before building a query string?

Filter the object entries before passing them to URLSearchParams:

const cleaned = Object.fromEntries(
  Object.entries(raw).filter(([_, v]) => v != null)
);

Final Thoughts

Building query strings from JavaScript objects is a task that seems trivial but hides real complexity. Special characters, arrays, nested objects, and encoding conventions all create opportunities for bugs.

The URL constructor with searchParams covers 90% of use cases safely. For complex serialization needs, build a custom serializer or use a dedicated library. But never manually concatenate query strings — that approach breaks the moment real user data enters your application.

For quick verification of how your object gets encoded, the URL Encoder/Decoder tool provides immediate feedback on different encoding approaches.