@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.
Maintainers
Readme
tl;dr
npm install @nauverse/make-urlimport { 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#commentsPass 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+moremake-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 keepswss://,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/1A 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/apiWithout a domain, you get a relative or an absolute path:
makeURL("blog", "posts/1");
// blog/posts/1makeURL("/blog", "posts/1");
// /blog/posts/1URL 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=postsA 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/1Values 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.000ZValues 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 pathAn empty value removes its path segment:
makeURL("https://example.com", "users/:id/posts", {
params: { id: "" },
});
// https://example.com/users/postsArrays 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=shoesIn 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%20startedA 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?tabHosts
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%9FemakeURL("http://[::1]:8080", "api");
// http://[::1]:8080/apimakeURL("http://localhost:3000", "api", "users");
// http://localhost:3000/api/usersmakeURL("https://user:[email protected]", "private");
// https://user:[email protected]/privateRelative paths and file names are not mistaken for domains:
makeURL("./docs", "index.html");
// ./docs/index.htmlProtocols
Any protocol followed by // is kept, and so are mailto:, tel:, sms:, urn: and data::
makeURL("wss://example.com", "socket");
// wss://example.com/socketmakeURL("mailto:[email protected]", { params: { subject: "Hi there" } });
// mailto:[email protected]?subject=Hi+thereThe 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/amakeURL("//example", "test/a");
// /example/test/aWhen 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: addshttps://if the URL starts with a domain.auto-insecure: addshttp://if the URL starts with a domain.httpsorhttp: always adds that protocol.none: never adds a protocol.
makeURL("example.com", "blog", { config: { forceProtocol: "auto" } });
// https://example.com/blogmakeURL("blog", "post/1", { config: { forceProtocol: "auto" } });
// blog/post/1makeURL("example.com", "blog", { config: { forceProtocol: "auto-insecure" } });
// http://example.com/blogmakeURL("blog", "post/1", { config: { forceProtocol: "https" } });
// https://blog/post/1makeURL("example.com", "blog", { config: { forceProtocol: "none" } });
// example.com/blogtrailingSlash
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/aallowEmptyPathSegments
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///amakeURL("https://example.com", "//test///a", {
config: { allowEmptyPathSegments: false },
});
// https://example.com/test/aarraySerializer
makeURL("https://example.com", "test", {
params: { arr: ["a", "b", "c"] },
config: { arraySerializer: "repeat" },
});
// https://example.com/test?arr=a&arr=b&arr=cmakeURL("https://example.com", "test", {
params: { arr: ["a", "b", "c"] },
config: { arraySerializer: "comma" },
});
// https://example.com/test?arr=a%2Cb%2CcmakeURL("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%5DThe 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/blogmakeURL("/blog", "post/1", { config: { strict: true } });
// Throws: The generated URL is not valid: /blog/post/1Global 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.
undefinedandnullparams 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, soforceProtocol: "auto"addshttps://to them. Use"auto-insecure"or writehttp://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)"givesjavascript%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://(likewss://ormyapp://) is kept, andmailto:,tel:,sms:,urn:anddata:URLs are supported. - With
trailingSlash: "remove"(the default), the slash after a host alone is removed too (https://example.com/giveshttps://example.com). getMakeURLDefaultConfigreturns a copy, andBASE_DEFAULT_MAKE_URL_CONFIGis 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
Thanks goes to these wonderful people (emoji key):
This project follows the all-contributors specification. Contributions of any kind welcome!
