URL #

The URL (Uniform Resource Locator) is the fundamental interface of the web — every HTTP request, every link, every API endpoint is represented as a URL. Correct URL manipulation isn’t just string concatenation — there’s special character encoding, path normalization, query parameter handling, and many edge cases that are easy to get wrong when done manually. Node.js provides URL and URLSearchParams as built-in APIs following the WHATWG URL standard — exactly the same as used in browsers, so the code you write on the server works in the browser without modification.

URL Anatomy #

Before writing code, it’s important to understand the components that make up a URL.

https://user:[email protected]:8080/v1/produk?q=laptop&halaman=2#hasil
│────│  │────────│ │─────────────│ │──│ │───────│ │─────────────────│ │───│
│    │  │        │ │             │ │  │ │       │ │                 │ │   │
protocol credential    hostname  port pathname  search (query)    hash
         (auth)
const url = new URL("https://user:[email protected]:8080/v1/produk?q=laptop&halaman=2#hasil");

console.log(url.protocol);  // "https:"
console.log(url.username);  // "user"
console.log(url.password);  // "pass"
console.log(url.hostname);  // "api.example.com"
console.log(url.port);      // "8080"
console.log(url.host);      // "api.example.com:8080" (hostname + port)
console.log(url.pathname);  // "/v1/produk"
console.log(url.search);    // "?q=laptop&halaman=2"
console.log(url.hash);      // "#hasil"
console.log(url.origin);    // "https://api.example.com:8080"
console.log(url.href);      // the full URL as a string

Creating and Parsing URLs #

The URL Constructor #

URL accepts an absolute URL string, or a relative URL with a base URL as the second argument.

// absolute URL
const url1 = new URL("https://example.com/path?q=hello");

// relative URL against a base
const url2 = new URL("/v1/users", "https://api.example.com");
console.log(url2.href); // "https://api.example.com/v1/users"

const url3 = new URL("../images/logo.png", "https://example.com/assets/css/");
console.log(url3.href); // "https://example.com/assets/images/logo.png"

// ANTI-PATTERN: string concatenation to build URLs
function getEndpointSalah(baseUrl: string, path: string, id: string): string {
  return baseUrl + "/" + path + "/" + id; // ✗ unsafe, no normalization
}

// CORRECT: use the URL API
function getEndpoint(baseUrl: string, path: string, id: string): string {
  return new URL(`${path}/${id}`, baseUrl).href; // ✓ automatic normalization
}

// URL validation — URL() throws a TypeError if invalid
function isURLValid(urlString: string): boolean {
  try {
    new URL(urlString);
    return true;
  } catch {
    return false;
  }
}

console.log(isURLValid("https://example.com")); // true
console.log(isURLValid("bukan url"));           // false
console.log(isURLValid("ftp://files.com"));     // true
console.log(isURLValid("/relative/path"));       // false — relative URL without a base

// validate http/https only
function isHTTPUrl(urlString: string): boolean {
  try {
    const url = new URL(urlString);
    return url.protocol === "http:" || url.protocol === "https:";
  } catch {
    return false;
  }
}

Modifying URL Components #

URL properties can be set directly — the URL is automatically updated and normalized.

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

// change the protocol
url.protocol = "http:";
console.log(url.href); // "http://example.com/path"

// change the hostname
url.hostname = "api.example.com";
console.log(url.href); // "http://api.example.com/path"

// change the port
url.port = "3000";
console.log(url.href); // "http://api.example.com:3000/path"

// add a path
url.pathname = "/v2/users";
console.log(url.href); // "http://api.example.com:3000/v2/users"

// remove the port — set it to an empty string
url.port = "";
console.log(url.href); // "http://api.example.com/v2/users"

// add a hash
url.hash = "section-1";
console.log(url.href); // "http://api.example.com/v2/users#section-1"

URLSearchParams — Query Strings #

URLSearchParams is a dedicated API for managing query parameters. It handles encoding/decoding of special characters automatically.

// create from a query string
const params1 = new URLSearchParams("q=laptop+gaming&kategori=elektronik&halaman=1");

// create from an object
const params2 = new URLSearchParams({
  q: "laptop gaming",
  kategori: "elektronik",
  halaman: "1",
});

// create from an array of pairs
const params3 = new URLSearchParams([
  ["q", "laptop gaming"],
  ["tag", "sale"],
  ["tag", "new"], // duplicate keys are allowed
]);

Query Parameter Operations #

const params = new URLSearchParams();

// set — replaces the value if the key already exists
params.set("q", "laptop");
params.set("halaman", "1");
params.set("perHalaman", "20");

// append — adds a new value even if the key exists
params.append("tag", "gaming");
params.append("tag", "sale");     // two values for the key "tag"

// get — takes the first value of the key
console.log(params.get("q"));       // "laptop"
console.log(params.get("tag"));     // "gaming" — only the first
console.log(params.get("kosong"));  // null — key doesn't exist

// getAll — takes all values of the key
console.log(params.getAll("tag")); // ["gaming", "sale"]

// has — checks whether the key exists
console.log(params.has("q"));       // true
console.log(params.has("kosong"));  // false

// delete — removes all values of the key
params.delete("tag");
console.log(params.has("tag")); // false

// convert to a string
console.log(params.toString());
// "q=laptop&halaman=1&perHalaman=20"

// iterate all parameters
for (const [key, value] of params) {
  console.log(`${key}: ${value}`);
}

// or with forEach
params.forEach((value, key) => {
  console.log(`${key} = ${value}`);
});

// sort — order parameters alphabetically (useful for consistent cache keys)
params.sort();
console.log(params.toString());
// "halaman=1&perHalaman=20&q=laptop"

Integrating URLSearchParams with URL #

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

// access searchParams from the URL object
url.searchParams.set("q", "laptop");
url.searchParams.set("kategori", "elektronik");
url.searchParams.append("tag", "gaming");

console.log(url.href);
// "https://api.example.com/produk?q=laptop&kategori=elektronik&tag=gaming"

// modify directly via searchParams
url.searchParams.delete("tag");
url.searchParams.set("halaman", "2");

console.log(url.search); // "?q=laptop&kategori=elektronik&halaman=2"

// read query parameters from an existing URL
const urlPencarian = new URL("https://shop.com/cari?q=laptop&hargaMax=5000000&stok=true");
const query = urlPencarian.searchParams.get("q");              // "laptop"
const hargaMax = Number(urlPencarian.searchParams.get("hargaMax")); // 5000000
const stokAda = urlPencarian.searchParams.get("stok") === "true";   // true

URL Encoding #

URLs can only contain certain ASCII characters. Other characters — spaces, non-ASCII characters, special characters — must be encoded in %XX format.

flowchart TD
    A[Character in URL] --> B{Safe character\nfor URLs?}
    B -- Yes --> C["Write as-is\nA-Z, a-z, 0-9, - _ . ~"]
    B -- No --> D["Encode: %XX\nSpace → %20\nä → %C3%A4"]
    D --> E[Valid URL]
    C --> E

encodeURIComponent vs encodeURI #

// encodeURIComponent — encodes everything except: A-Z a-z 0-9 - _ . ! ~ * ' ( )
// use for query parameter values or path segments
console.log(encodeURIComponent("laptop gaming"));    // "laptop%20gaming"
console.log(encodeURIComponent("harga=10.000"));     // "harga%3D10.000"
console.log(encodeURIComponent("nama/dengan/slash")); // "nama%2Fdengan%2Fslash"
console.log(encodeURIComponent("bahasa Indonesia")); // "bahasa%20Indonesia"

// encodeURI — encodes a full URL, but does NOT encode URL structural characters
// : / ? # [ ] @ ! $ & ' ( ) * + , ; =
// use for encoding an already-formed URL (rarely needed)
console.log(encodeURI("https://example.com/path dengan spasi?q=nilai"));
// "https://example.com/path%20dengan%20spasi?q=nilai"
// note: ? and = are not encoded because they're part of the URL structure

// ANTI-PATTERN: encoding query parameters with encodeURI
function buatURLSalah(base: string, keyword: string): string {
  return encodeURI(`${base}?q=${keyword}`); // ✗ doesn't encode & = characters in the keyword
}

// CORRECT: use URLSearchParams which encodes automatically
function buatURL(base: string, params: Record<string, string>): string {
  const url = new URL(base);
  for (const [key, value] of Object.entries(params)) {
    url.searchParams.set(key, value); // ✓ automatic encoding
  }
  return url.href;
}

console.log(buatURL("https://example.com/cari", {
  q: "laptop & tablet",    // & inside the value — encoded automatically
  kategori: "elektronik=premium", // = inside the value — encoded automatically
}));
// "https://example.com/cari?q=laptop+%26+tablet&kategori=elektronik%3Dpremium"

// decode
console.log(decodeURIComponent("laptop%20gaming"));  // "laptop gaming"
console.log(decodeURIComponent("harga%3D10.000"));   // "harga=10.000"

URL Normalization #

Textually different URLs can refer to the same resource. Normalization ensures a consistent representation.

// URL() already does basic normalization
const url1 = new URL("HTTPS://Example.COM/PATH/../to/./resource");
console.log(url1.href);
// "https://example.com/to/resource" — protocol & hostname lowercased, path normalized

// trailing slash normalization
function normalisasiURL(urlString: string): string {
  const url = new URL(urlString);

  // remove the trailing slash on the pathname (except when it's just "/")
  if (url.pathname !== "/" && url.pathname.endsWith("/")) {
    url.pathname = url.pathname.slice(0, -1);
  }

  // sort query parameters for consistency
  url.searchParams.sort();

  // remove the fragment (hash) — usually irrelevant for APIs
  url.hash = "";

  return url.href;
}

console.log(normalisasiURL("https://example.com/path/?b=2&a=1#section"));
// "https://example.com/path?a=1&b=2"

// extract and normalize a domain
function normalisasiDomain(urlString: string): string {
  const url = new URL(urlString.startsWith("http") ? urlString : `https://${urlString}`);
  return url.hostname.toLowerCase();
}

console.log(normalisasiDomain("HTTPS://WWW.Example.COM/path")); // "www.example.com"
console.log(normalisasiDomain("example.com"));                   // "example.com"

// check whether two URLs refer to the same resource
function urlSama(a: string, b: string): boolean {
  return normalisasiURL(a) === normalisasiURL(b);
}

console.log(urlSama(
  "https://example.com/path/?b=2&a=1",
  "https://example.com/path?a=1&b=2"
)); // true

Common Patterns in Applications #

URL Builder for an API Client #

class APIClient {
  private baseURL: URL;

  constructor(baseURL: string, private apiKey?: string) {
    this.baseURL = new URL(baseURL);
  }

  // build an endpoint URL with path and parameters
  buildURL(
    path: string,
    params?: Record<string, string | number | boolean | undefined>
  ): string {
    // combine the base path with the endpoint path
    const url = new URL(
      path.startsWith("/") ? path : `/${path}`,
      this.baseURL
    );

    // add the API key if present
    if (this.apiKey) {
      url.searchParams.set("apiKey", this.apiKey);
    }

    // add the given parameters (skip undefined/null)
    if (params) {
      for (const [key, value] of Object.entries(params)) {
        if (value !== undefined && value !== null) {
          url.searchParams.set(key, String(value));
        }
      }
    }

    return url.href;
  }

  // build a URL with a path containing dynamic segments
  buildURLWithSegments(
    template: string,
    segments: Record<string, string>,
    params?: Record<string, string | number | undefined>
  ): string {
    // replace :param with encoded values
    const path = template.replace(/:(\w+)/g, (_, key) => {
      const value = segments[key];
      if (value === undefined) throw new Error(`Segment :${key} tidak ditemukan`);
      return encodeURIComponent(value);
    });

    return this.buildURL(path, params);
  }
}

// usage
const client = new APIClient("https://api.tokoku.com/v2", "sk_test_1234567890");

console.log(client.buildURL("/produk", {
  kategori: "elektronik",
  hargaMin: 100000,
  hargaMax: 5000000,
  halaman: 1,
}));
// "https://api.tokoku.com/v2/produk?apiKey=sk_test_1234567890&kategori=elektronik&hargaMin=100000&..."

console.log(client.buildURLWithSegments(
  "/produk/:id/ulasan",
  { id: "prod-abc-123" },
  { halaman: 1, perHalaman: 10 }
));
// "https://api.tokoku.com/v2/produk/prod-abc-123/ulasan?apiKey=...&halaman=1&perHalaman=10"

Parsing Query Parameters from a Request #

// parse the query string from an incoming request URL
function parseQueryParams(urlString: string): Record<string, string | string[]> {
  const url = new URL(urlString);
  const result: Record<string, string | string[]> = {};

  for (const key of new Set(url.searchParams.keys())) {
    const values = url.searchParams.getAll(key);
    // if only one value, store as a string; more than one as an array
    result[key] = values.length === 1 ? values[0] : values;
  }

  return result;
}

console.log(parseQueryParams(
  "https://example.com/cari?q=laptop&tag=gaming&tag=sale&halaman=1"
));
// { q: "laptop", tag: ["gaming", "sale"], halaman: "1" }

// helper: get a value with the correct type
function getQueryParam(url: URL, key: string): string | null;
function getQueryParam(url: URL, key: string, defaultValue: string): string;
function getQueryParam(url: URL, key: string, defaultValue?: string): string | null {
  return url.searchParams.get(key) ?? defaultValue ?? null;
}

function getQueryParamInt(url: URL, key: string, defaultValue: number = 0): number {
  const val = url.searchParams.get(key);
  const parsed = val !== null ? parseInt(val, 10) : NaN;
  return isNaN(parsed) ? defaultValue : parsed;
}

function getQueryParamBool(url: URL, key: string, defaultValue: boolean = false): boolean {
  const val = url.searchParams.get(key);
  if (val === null) return defaultValue;
  return val === "true" || val === "1" || val === "yes";
}

// usage in a request handler
function handleProdukRequest(requestURL: string): void {
  const url = new URL(requestURL);

  const query = getQueryParam(url, "q", "");
  const halaman = getQueryParamInt(url, "halaman", 1);
  const perHalaman = getQueryParamInt(url, "perHalaman", 20);
  const hargaMax = getQueryParamInt(url, "hargaMax", 0);
  const stokAda = getQueryParamBool(url, "stokAda", false);

  console.log({ query, halaman, perHalaman, hargaMax, stokAda });
}

URL Security — Preventing Open Redirects #

An open redirect happens when an application forwards users to a URL that comes from input without validation — attackers can direct users to a malicious site.

// ANTI-PATTERN: redirecting to an input URL without validation
function redirectSalah(req: any, res: any): void {
  const tujuan = req.query.redirect;
  res.redirect(tujuan); // ✗ can redirect to any site!
}

// CORRECT: validate the redirect URL only against allowed domains
function isRedirectAman(
  urlInput: string,
  domainDiizinkan: string[]
): boolean {
  try {
    const url = new URL(urlInput);
    return domainDiizinkan.includes(url.hostname);
  } catch {
    // invalid URL — maybe a relative path like "/dashboard"
    // relative paths are safe because they can't redirect to another domain
    return urlInput.startsWith("/") && !urlInput.startsWith("//");
  }
}

function getURLRedirectAman(
  urlInput: string,
  domainDiizinkan: string[],
  fallback: string = "/"
): string {
  return isRedirectAman(urlInput, domainDiizinkan) ? urlInput : fallback;
}

// usage
const domainSah = ["tokoku.com", "www.tokoku.com", "api.tokoku.com"];

console.log(isRedirectAman("https://tokoku.com/dashboard", domainSah));  // true
console.log(isRedirectAman("https://evil.com/phishing", domainSah));     // false
console.log(isRedirectAman("/dashboard", domainSah));                     // true (relative)
console.log(isRedirectAman("//evil.com", domainSah));                    // false (protocol-relative)

// in an Express middleware
function redirectMiddleware(req: any, res: any): void {
  const tujuanRaw = req.query.redirect || "/";
  const tujuanAman = getURLRedirectAman(tujuanRaw, domainSah);
  res.redirect(302, tujuanAman);
}

Extracting Information from URLs #

// extract all path segments
function getPathSegments(urlString: string): string[] {
  const url = new URL(urlString);
  return url.pathname
    .split("/")
    .filter((segment) => segment.length > 0); // remove empty strings
}

console.log(getPathSegments("https://example.com/v1/produk/laptop-asus-123"));
// ["v1", "produk", "laptop-asus-123"]

// extract the file extension from a URL
function getFileExtension(urlString: string): string {
  const url = new URL(urlString);
  const filename = url.pathname.split("/").pop() ?? "";
  const dotIndex = filename.lastIndexOf(".");
  return dotIndex > 0 ? filename.slice(dotIndex) : "";
}

console.log(getFileExtension("https://cdn.example.com/images/foto.jpg"));  // ".jpg"
console.log(getFileExtension("https://example.com/api/v1/data"));          // ""

// check whether a URL points to a specific resource
function isAPIEndpoint(urlString: string, apiBasePath: string = "/api"): boolean {
  try {
    const url = new URL(urlString);
    return url.pathname.startsWith(apiBasePath);
  } catch {
    return false;
  }
}

// parse a URL into a more structured object
interface ParsedURL {
  protocol: string;
  hostname: string;
  port: string | null;
  path: string;
  segments: string[];
  query: Record<string, string | string[]>;
  hash: string | null;
}

function parseURL(urlString: string): ParsedURL {
  const url = new URL(urlString);

  const query: Record<string, string | string[]> = {};
  for (const key of new Set(url.searchParams.keys())) {
    const values = url.searchParams.getAll(key);
    query[key] = values.length === 1 ? values[0] : values;
  }

  return {
    protocol: url.protocol.replace(":", ""),
    hostname: url.hostname,
    port: url.port || null,
    path: url.pathname,
    segments: url.pathname.split("/").filter(Boolean),
    query,
    hash: url.hash ? url.hash.slice(1) : null, // remove the leading '#'
  };
}

console.log(parseURL("https://api.example.com:8080/v1/produk?q=laptop&tag=gaming#atas"));
// {
//   protocol: "https",
//   hostname: "api.example.com",
//   port: "8080",
//   path: "/v1/produk",
//   segments: ["v1", "produk"],
//   query: { q: "laptop", tag: "gaming" },
//   hash: "atas"
// }

When to Use URL vs Plain Strings #

Use the URL API for:
  ✓ Parsing incoming request URLs — get the path, query, hostname
  ✓ Building URLs with query parameters — URLSearchParams handles encoding automatically
  ✓ Modifying URL components — change the hostname, add parameters, change the path
  ✓ Normalizing URLs for comparison or cache keys
  ✓ Validating whether a string is a valid URL
  ✓ Combining a base URL with a relative path

Plain strings are fine for:
  ✗ URLs whose format is fixed and don't need modification
  ✗ Simple URL templates without special characters in parameters
  ✗ Logging already-formed URLs — no parsing needed

Summary #

  • The URL API is the WHATWG standard — runs identically in Node.js and browsers; use it instead of manual string manipulation for all URL operations.
  • URLSearchParams for query strings — handles encoding/decoding of special characters automatically; don’t concatenate query parameters manually because it’s prone to encoding bugs.
  • url.searchParams.set() not concatenation — when adding parameters to an existing URL, always use searchParams.set() so encoding happens correctly.
  • encodeURIComponent for values, not full URLs — use it to encode query parameter values or individual path segments; let URLSearchParams handle the overall encoding.
  • URL() throws a TypeError for invalid URLs — take advantage of this for URL validation with try/catch; no complicated regex needed.
  • Normalize URLs before comparing them — the same URL can be written differently (case, trailing slash, parameter order); normalize first for accurate comparison.
  • Validate redirect URLs against a domain list — don’t redirect to a raw URL from user input; always check the hostname against a whitelist to prevent open redirect attacks.
  • Relative paths starting with / are safe for redirects — only absolute URLs can point to another domain; relative paths always stay within the same domain.

← Previous: Crypto   Next: Buffer →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact