npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@nauverse/make-url

v2.0.0

Published

A tiny, dependency-free URL builder for TypeScript and JavaScript: path params, query params, hash, protocols and slashes handled for you.

Readme

tl;dr

npm install @nauverse/make-url
import { makeURL } from "@nauverse/make-url";

makeURL("https://api.example.com/", "/users/:id/posts/", {
  params: { id: 42, limit: 10, tags: ["news", "tech"] },
  hash: "comments",
});
// https://api.example.com/users/42/posts?limit=10&tags=news&tags=tech#comments

Pass any number of fragments, then optionally an options object. :id is replaced in the path, every other param becomes a query param, everything is encoded, and the slashes are cleaned up.

Why make-url?

Building URLs by hand looks easy until the details show up:

const API_URL = "https://api.example.com/";

// Wrong: "https://api.example.com//users/..." and nothing is encoded
const url = `${API_URL}/users/${id}/posts?limit=${limit}&search=${search}`;

The built-in URL and URLSearchParams classes encode the query, but you still have to encode every path value yourself and handle the slashes between the base URL and the path. That is what make-url does for you, in one call:

makeURL("https://api.example.com/", "/users/:id/posts", {
  params: { id: "a/b", limit: 10, search: "café & more" },
});
// https://api.example.com/users/a%2Fb/posts?limit=10&search=caf%C3%A9+%26+more

make-url vs urlcat and url-join

make-url was inspired by urlcat, and it covers more cases with a smaller bundle. Every output below was produced by running the three libraries (urlcat 3.1.0, url-join 5.0.0, make-url 2.0.0):

| Case | urlcat | url-join | make-url | | ---- | ------ | -------- | -------- | | More than two fragments: ("https://api.example.com", "v1", "users") | https://api.example.com/v1?0=u&1=s&2=e&3=r&4=s (the third argument is read as params) | https://api.example.com/v1/users | https://api.example.com/v1/users | | Hash | No option (a # written in the path is not encoded: #a b) | No option (same) | hash: "install guide" gives #install%20guide | | Domain without protocol: ("example.com", "x") | example.com/x | example.com/x | https://example.com/x (configurable with forceProtocol) | | Trailing slash | Kept as written | Kept as written | Removed or added, your choice (trailingSlash) | | Arrays in the query: tags: ["a", "b"] | tags%5B0%5D=a&tags%5B1%5D=b | No params support | tags=a&tags=b, or tags=a%2Cb, or JSON (arraySerializer) | | Objects in the query: f: { a: 1 } | Bracket notation (f%5Ba%5D=1) | No params support | JSON (f=%7B%22a%22%3A1%7D) | | Empty path param: (":id", { id: "" }) | Throws | No params support | Removes the empty segment | | International domain: https://münchen.de | https://münchen.de/x | https://münchen.de/x | https://xn--mnchen-3ya.de/x (punycode, valid everywhere) | | URL validation | None | None | strict: true throws if the result is not a complete URL | | Global defaults | No | No | setMakeURLDefaultConfig | | Size (minified + gzipped, dependencies included) | 14.4 kB (it depends on qs) | 0.4 kB (joins strings only) | 3.1 kB, zero dependencies | | Last release | January 2023 | March 2022 | Maintained |

Features

  • Zero dependencies, ESM and CommonJS builds, TypeScript types included
  • Any number of fragments: the protocol, the domain and the path can be split however you like
  • URL params (:id) replaced in the path, never in the host or the port
  • Query params of any type: strings, numbers, booleans, dates, objects and arrays (three array formats)
  • Hash support, including client-side routes (#/users/1?tab=a)
  • A query or hash already written in the last fragment is kept
  • Safe encoding everywhere, without encoding existing escapes (%20) twice
  • Smart protocol handling: adds https:// only when there is a domain, and keeps wss://, myapp://, mailto: or the relative protocol (//example.com)
  • Hosts: domains, international domains (punycode), IPv4, IPv6 ([::1]), localhost, ports and credentials (user:password@)
  • Configurable trailing slash, empty path segments and validation (strict)
  • Global defaults so you do not repeat your config

Installation

npm install @nauverse/make-url
// ESM
import { makeURL } from "@nauverse/make-url";
// CommonJS
const { makeURL } = require("@nauverse/make-url");

It runs in every modern browser and in Node.js 22 and later, since it only uses the standard URL and URLSearchParams APIs.

Guide

makeURL takes one or more string fragments, optionally followed by an options object with three optional fields: params, hash and config. The options object must be the last argument.

Fragments

The fragments are joined with /, and repeated slashes are removed:

makeURL("https://example.com/", "/blog/", "//posts/", "1");
// https://example.com/blog/posts/1

A fragment can contain several path segments ("blog/posts"), and the protocol, the domain and the port can be split across fragments:

makeURL("https://example.com", ":8080", "api");
// https://example.com:8080/api

Without a domain, you get a relative or an absolute path:

makeURL("blog", "posts/1");
// blog/posts/1
makeURL("/blog", "posts/1");
// /blog/posts/1

URL params and query params

Each key of params found in the path as :<key> is replaced by its encoded value. The other params are added to the query:

makeURL("https://example.com", "users/:id", {
  params: { id: 12, tab: "posts" },
});
// https://example.com/users/12?tab=posts

A key only matches whole names: :id does not match the start of :identifier.

makeURL("https://example.com", ":identifier/:id", {
  params: { id: 1, identifier: "abc" },
});
// https://example.com/abc/1

Values can be of any type. undefined and null are skipped, dates are sent as ISO strings and objects as JSON:

makeURL("https://example.com", "search", {
  params: { q: "café", page: 2, draft: false, from: new Date(0), missing: undefined },
});
// https://example.com/search?q=caf%C3%A9&page=2&draft=false&from=1970-01-01T00%3A00%3A00.000Z

Values are safe to take from users: they are encoded, so they can never change the host, add path segments or add query params. A value that would create a . or .. path segment (a path traversal) throws:

makeURL("https://example.com", "users/:id/delete", { params: { id: ".." } });
// Throws: The value of the param "id" can not be "." or ".." in the path

An empty value removes its path segment:

makeURL("https://example.com", "users/:id/posts", {
  params: { id: "" },
});
// https://example.com/users/posts

Arrays always go to the query (see arraySerializer), even when the path contains their key.

Query and hash in the fragments

A query or a hash written in the last fragment is kept. The params are added after the existing query, and the hash option replaces the existing hash:

makeURL("https://example.com/search?sort=new", {
  params: { q: "shoes" },
});
// https://example.com/search?sort=new&q=shoes

In any other fragment, ? and # are encoded, since they can not be part of a query or a hash there.

Hash

makeURL("https://example.com", "docs", { hash: "getting started" });
// https://example.com/docs#getting%20started

A leading # is ignored, and /, ?, : and @ are kept, so client-side routes work:

makeURL("https://example.com", { hash: "#/users/1?tab" });
// https://example.com#/users/1?tab

Hosts

International domains are converted to punycode. IPv6 addresses, localhost, ports and credentials are supported:

makeURL("münchen.de", "straße");
// https://xn--mnchen-3ya.de/stra%C3%9Fe
makeURL("http://[::1]:8080", "api");
// http://[::1]:8080/api
makeURL("http://localhost:3000", "api", "users");
// http://localhost:3000/api/users
makeURL("https://user:[email protected]", "private");
// https://user:[email protected]/private

Relative paths and file names are not mistaken for domains:

makeURL("./docs", "index.html");
// ./docs/index.html

Protocols

Any protocol followed by // is kept, and so are mailto:, tel:, sms:, urn: and data::

makeURL("wss://example.com", "socket");
// wss://example.com/socket
makeURL("mailto:[email protected]", { params: { subject: "Hi there" } });
// mailto:[email protected]?subject=Hi+there

The relative protocol (//) is kept when it is followed by a domain. Otherwise the URL becomes an absolute path:

makeURL("//example.com", "test/a");
// //example.com/test/a
makeURL("//example", "test/a");
// /example/test/a

When there is no protocol, forceProtocol decides what to add.

Configuration

| Option | Values | Default | What it does | | ------ | ------ | ------- | ------------ | | forceProtocol | "auto", "auto-insecure", "http", "https", "none" | "auto" | The protocol added when the URL has none | | trailingSlash | "remove", "add" | "remove" | Whether the path ends with a slash | | allowEmptyPathSegments | true, false | false | Whether repeated slashes are kept | | arraySerializer | "repeat", "comma", "stringify" | "repeat" | How arrays are added to the query | | strict | true, false | false | Whether to throw when the result is not a complete URL |

Pass them in config for one call, or set them for every call with setMakeURLDefaultConfig. Options set to undefined keep their default value.

forceProtocol

It only applies when the first fragment has no protocol. A protocol you write is always kept.

  • auto: adds https:// if the URL starts with a domain.
  • auto-insecure: adds http:// if the URL starts with a domain.
  • https or http: always adds that protocol.
  • none: never adds a protocol.
makeURL("example.com", "blog", { config: { forceProtocol: "auto" } });
// https://example.com/blog
makeURL("blog", "post/1", { config: { forceProtocol: "auto" } });
// blog/post/1
makeURL("example.com", "blog", { config: { forceProtocol: "auto-insecure" } });
// http://example.com/blog
makeURL("blog", "post/1", { config: { forceProtocol: "https" } });
// https://blog/post/1
makeURL("example.com", "blog", { config: { forceProtocol: "none" } });
// example.com/blog

trailingSlash

makeURL("https://example.com", "test/a", { config: { trailingSlash: "add" } });
// https://example.com/test/a/
makeURL("https://example.com", "test/a/", { config: { trailingSlash: "remove" } });
// https://example.com/test/a

allowEmptyPathSegments

RFC 3986 allows empty path segments (https://example.com//users), but most servers do not expect them, so they are removed by default. Set it to true to keep every slash you write:

makeURL("https://example.com", "//test///a", {
  config: { allowEmptyPathSegments: true },
});
// https://example.com//test///a
makeURL("https://example.com", "//test///a", {
  config: { allowEmptyPathSegments: false },
});
// https://example.com/test/a

arraySerializer

makeURL("https://example.com", "test", {
  params: { arr: ["a", "b", "c"] },
  config: { arraySerializer: "repeat" },
});
// https://example.com/test?arr=a&arr=b&arr=c
makeURL("https://example.com", "test", {
  params: { arr: ["a", "b", "c"] },
  config: { arraySerializer: "comma" },
});
// https://example.com/test?arr=a%2Cb%2Cc
makeURL("https://example.com", "test", {
  params: { arr: ["a", "b", "c"] },
  config: { arraySerializer: "stringify" },
});
// https://example.com/test?arr=%5B%22a%22%2C%22b%22%2C%22c%22%5D

The items are serialized like single values, and undefined and null items are skipped. An empty array adds nothing with repeat, an empty value with comma and [] with stringify.

strict

With strict: true, makeURL throws if the result is not a complete URL with a valid host (a domain, an IP address or localhost). Relative URLs, absolute paths and the relative protocol are rejected:

makeURL("example.com", "blog", { config: { strict: true } });
// https://example.com/blog
makeURL("/blog", "post/1", { config: { strict: true } });
// Throws: The generated URL is not valid: /blog/post/1

Global defaults

Call setMakeURLDefaultConfig once, as early as possible (the entry file of your app), to change the defaults of every call. Each call starts from the base defaults, so pass every setting you want to change:

setMakeURLDefaultConfig({ trailingSlash: "add", strict: true });

makeURL("example.com", "blog");
// https://example.com/blog/

getMakeURLDefaultConfig returns a copy of the current defaults, and BASE_DEFAULT_MAKE_URL_CONFIG holds the base defaults:

getMakeURLDefaultConfig();
/*
{
  forceProtocol: "auto",
  trailingSlash: "remove",
  strict: false,
  allowEmptyPathSegments: false,
  arraySerializer: "repeat"
}
*/

TypeScript

The package includes its type definitions. The types of the options are exported:

import { makeURL, type IConfig, type MakeURLOptions } from "@nauverse/make-url";

const config: Partial<IConfig> = { trailingSlash: "add" };
const options: MakeURLOptions = { params: { id: 1 }, config };

makeURL("example.com", "users/:id", options);

Exported types: IConfig, IParams, MakeURLOptions, MakeURLArgs, TForceProtocol, TTrailingSlash and TArraySerializer.

Upgrading from 1.x

Version 2 fixes many wrong URLs, so some results change. Check these cases in your code:

  • Node.js 22 or later is required.
  • undefined and null params are skipped (they were added as "undefined" and "null"), and dates are sent as ISO strings (they were quoted JSON).
  • localhost, IPv6 hosts ([::1]) and international domains are detected as hosts, so forceProtocol: "auto" adds https:// to them. Use "auto-insecure" or write http:// for local servers.
  • Relative paths (./page, ../page) and file names (index.html, data.json) are no longer taken as domains, so no protocol is added to them.
  • A param value that would create a . or .. path segment throws, and without a host, a forced protocol is not added when the first path segment contains a param (the value would become the host).
  • A URL without host never starts with //, and a first path segment that looks like a protocol has its : encoded ("javascript:alert(1)" gives javascript%3Aalert(1)), so path fragments and params can never create a link to another host.
  • A query or hash written in the last fragment ("search?q=1", "page#top") is kept instead of being encoded.
  • The hash keeps /, ?, : and @, and a leading # is ignored.
  • Any protocol:// (like wss:// or myapp://) is kept, and mailto:, tel:, sms:, urn: and data: URLs are supported.
  • With trailingSlash: "remove" (the default), the slash after a host alone is removed too (https://example.com/ gives https://example.com).
  • getMakeURLDefaultConfig returns a copy, and BASE_DEFAULT_MAKE_URL_CONFIG is frozen.
  • The types are stricter: the options object can only be the last argument.

Help

Thank you for using make-url!

If you need any help using this library, feel free to create a GitHub issue and ask your questions. I'll try to answer as quickly as possible.

Contribute

Contributions of any kind (pull requests, bug reports, feature requests, documentation, design) are more than welcome! Read the contributing guide to get started (and AGENTS.md if you use a coding agent).

Contributors

All Contributors

Thanks goes to these wonderful people (emoji key):

This project follows the all-contributors specification. Contributions of any kind welcome!