URL Input Validation with the URL Constructor

How do you validate a “Website” field that must accept example.com, www.example.com/shop and https://example.co.uk/about?ref=1, but reject javascript:alert(1), http://localhost:3000 and not a url? A regular expression cannot do it reliably; the browser’s own URL parser can. This recipe adds a scheme when the user leaves it out, parses with the URL constructor, allows only http and https, requires a real public hostname, normalises the result for storage, and reports failures through setCustomValidity() so the field behaves like every other constraint in the Constraint Validation API flow.

When to Use Parser-Based URL Validation

Use it for any field where users type a web address: personal or company websites, social profile links, portfolio links, webhook targets in developer settings, and “link to the item” fields in marketplaces. It is especially important when:

  • Users type addresses the way they read them, without https://, which type="url" rejects.
  • The URL will be rendered as a link for other users, where a javascript: or data: scheme would be a stored cross-site scripting vector.
  • The server will fetch the URL (link previews, webhooks), where localhost and private network addresses would enable server-side request forgery.

Native type="url" is still fine for technical audiences who paste full URLs, but for general forms it rejects too much and checks too little. The comparison between native attributes and custom rules is covered in the HTML5 input types and attributes topic.

type=url versus the URL constructor with a policy Two columns comparing the native type url input with parsing through the URL constructor plus an explicit scheme and host policy. type="url" ✗ rejects example.com (no scheme) ✗ accepts javascript: and data: URLs ✗ accepts http://localhost ✓ zero code, native keyboard URL() + policy ✓ adds https:// when missing ✓ allows only http and https ✓ rejects local and private hosts ✓ returns a normalised href to store
The native check rejects friendly input and accepts dangerous schemes; parsing plus a policy does the opposite.

Minimal Working URL Validator

export interface UrlResult { error: string; href?: string }

const ALLOWED_PROTOCOLS = new Set(["http:", "https:"]);

function withScheme(raw: string): string {
  const v = raw.trim();
  // A scheme is letters followed by ":". "localhost:3000" is ambiguous, so require "//" after it.
  return /^[a-z][a-z\d+.-]*:\/\//i.test(v) || /^[a-z][a-z\d+.-]*:(?!\d)/i.test(v) ? v : `https://${v}`;
}

function isPrivateHost(host: string): boolean {
  if (host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local") || host.endsWith(".internal")) return true;
  const ipv4 = host.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
  if (ipv4) {
    const [a, b] = [Number(ipv4[1]), Number(ipv4[2])];
    return a === 10 || a === 127 || a === 0 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 169 && b === 254);
  }
  return host.startsWith("[");                        // IPv6 literals: reject on public forms
}

export function checkWebsite(raw: string): UrlResult {
  if (raw.trim() === "") return { error: "" };        // optional field
  let url: URL;
  try {
    url = new URL(withScheme(raw));
  } catch {
    return { error: "Enter a web address like example.com or https://example.com/page." };
  }
  if (!ALLOWED_PROTOCOLS.has(url.protocol)) return { error: "Only web addresses starting with http or https can be used." };
  if (url.username || url.password) return { error: "Remove the username or password from the address." };
  const host = url.hostname;
  if (!host.includes(".") || host.endsWith(".")) return { error: "Include the full domain, like example.com." };
  if (isPrivateHost(host)) return { error: "Enter a public website address." };
  url.hash = "";                                     // fragments are client-side only
  return { error: "", href: url.href };
}

// Wiring
const field = document.querySelector<HTMLInputElement>("#website")!;
const hidden = document.querySelector<HTMLInputElement>("#website-normalised")!;

function validate(normalise: boolean): void {
  const r = checkWebsite(field.value);
  field.setCustomValidity(r.error);
  hidden.value = r.href ?? "";
  if (normalise && r.href) field.value = r.href.replace(/\/$/, "");   // show what will be saved
}

field.addEventListener("input", () => validate(false));
field.addEventListener("blur", () => validate(true));
field.form!.addEventListener("submit", (event) => {
  validate(false);
  if (!field.form!.checkValidity()) {
    event.preventDefault();
    field.form!.reportValidity();
  }
});

Showing the normalised value on blur — example.com becomes https://example.com — tells the user exactly what will be saved and makes it easy to spot a typo in the domain. It also means the value in the field is the value the server will receive.

From typed text to a stored URL Typed text gets a scheme if missing, is parsed by the URL constructor, checked against an allowed-protocol list and a public-host policy, and stored as a normalised href. Typed example.com/shop Add scheme https://example. com/shop new URL() parse or reject Policy http(s), public host, no credentials Store href normalised, no fragment
The parser does the hard work; the policy steps decide which parseable URLs are acceptable for this field.

URL Validator Option Reference

Option Type Default Purpose
ALLOWED_PROTOCOLS Set<string> http:, https: Blocks javascript:, data:, file:, mailto:
Default scheme string https:// Added when the user types a bare domain
Private-host rule function localhost, RFC 1918, link-local, IPv6 literals Prevents internal addresses on public forms
Credentials policy rejected https://user:pass@host leaks secrets into profiles
Fragment policy removed #section is not meaningful to store
Display on blur behaviour normalised href Shows exactly what will be saved
inputmode attribute url URL keyboard without type="url" rules

Verification Steps

import { describe, it, expect } from "vitest";
import { checkWebsite } from "./website";

describe("checkWebsite", () => {
  it.each([
    ["example.com", "https://example.com/"],
    ["www.example.com/shop?x=1#top", "https://www.example.com/shop?x=1"],
    ["HTTP://Example.COM", "http://example.com/"],
    ["münchen.de", "https://xn--mnchen-3ya.de/"],
  ])("normalises %s", (raw, href) => expect(checkWebsite(raw)).toEqual({ error: "", href }));

  it.each(["javascript:alert(1)", "data:text/html,hi", "http://localhost:3000", "http://10.0.0.5", "https://user:pw@example.com", "not a url"])(
    "rejects %s", (raw) => expect(checkWebsite(raw).error).not.toBe(""),
  );
});

Edge Cases and Failure Modes

Internationalised domains. The URL parser converts münchen.de to its punycode form xn--mnchen-3ya.de. Store the punycode (it is the canonical form), but consider displaying the Unicode form to users, and be aware that punycode display is also how browsers defend against lookalike domains.

“Validity” is not reachability. A parseable, public-looking URL may not resolve. If it matters — a webhook target, a portfolio link that will be shown to recruiters — check reachability on the server, asynchronously, with a timeout and without following redirects to private addresses.

Server-side fetching needs its own checks. The private-host rule here checks the hostname text; an attacker can register a public domain that resolves to 127.0.0.1. Any server that fetches user URLs must resolve the name and check the resulting IP address, then pin that address for the request. The general principle is in why client-side validation is not security.

Trailing punctuation from copy-paste. URLs copied from sentences often end with . or ). Trim trailing punctuation that is unlikely to be part of the address before parsing, or the domain check rejects example.com. with a confusing message.

Field-Specific URL Policies

“Website” is only one kind of URL field, and each kind deserves its own policy on top of the same parser. A social profile field should check the hostname belongs to that network — linkedin.com or www.linkedin.com for a LinkedIn field — and can accept a bare handle, building the URL for the user. A webhook target in developer settings should require https:, allow ports, and forbid private hosts on the server after DNS resolution. A “link to the product” field in a marketplace might restrict to your own domain. Express these as small functions that receive the parsed URL object, so the parsing, scheme defaulting and error wording stay shared.

type UrlPolicy = (url: URL) => string;

export const linkedInOnly: UrlPolicy = (url) =>
  /(^|\.)linkedin\.com$/.test(url.hostname) && url.pathname.startsWith("/in/")
    ? ""
    : "Enter your LinkedIn profile address, like linkedin.com/in/your-name.";

export const httpsOnly: UrlPolicy = (url) => (url.protocol === "https:" ? "" : "Webhook addresses must start with https.");

export function checkUrl(raw: string, ...policies: UrlPolicy[]): UrlResult {
  const base = checkWebsite(raw);
  if (base.error || !base.href) return base;
  const url = new URL(base.href);
  for (const p of policies) {
    const error = p(url);
    if (error) return { error };
  }
  return base;
}

Composing policies this way mirrors the approach in composing pure validator functions: each rule is small, pure and testable on its own, and the field’s full behaviour is just the list of rules it uses.

Rendering User URLs Safely

Validation is only half of URL safety; rendering is the other half. Always render user-supplied URLs from the normalised href your validator returned, never from the raw input, so the protocol check you ran is the one that applies. Add rel="nofollow ugc noopener" to user links: nofollow ugc tells search engines the link was user-generated, and noopener prevents the linked page from controlling your tab. Escape the URL when placing it in an attribute, which every templating layer does by default — and do not bypass that with raw HTML insertion. If you show a link preview, fetch it server-side with the safeguards above and cache the result, rather than letting the browser load arbitrary third-party resources into your page.

Should this typed value be accepted as a website? A decision tree that adds a scheme when missing, then checks parsing, protocol, credentials and host to accept or reject a website field value. Parses with URL() after adding https:// if needed? no Enter a web address like example.com yes Protocol is http or https? no Only http or https addresses yes Public host with a dot, no credentials? no Enter a public website address yes Store normalised href
Each rejection maps to one specific message; only a parseable, public http(s) address without credentials is stored.

Frequently Asked Questions

Should I use input type url for a website field?

Usually not for general audiences. It rejects addresses typed without a scheme, like example.com, and still accepts dangerous schemes. Use a text input with inputmode="url", add https:// when missing, and parse with the URL constructor.

How do I validate a URL in JavaScript without a regex?

Pass it to new URL() inside try/catch, or use URL.canParse(). Then apply a policy to the parsed parts: allowed protocols, a hostname with a dot, no credentials, and no private or local hosts.

How do I stop javascript: links in a URL field?

Allow-list protocols after parsing — only http: and https: — and render links from the normalised href your validator produced, never from the raw input.

Does a valid URL mean the website exists?

No. Parsing only proves the address is well-formed. If reachability matters, check it on the server with a timeout, resolving the hostname and refusing private IP addresses.

← Back to Identity Text Field Validation