Phone number validation sounds simple until you go international. US numbers have 10 digits, UK mobiles have 11, German numbers range from 7 to 12, and India uses a completely different prefix system. A basic regex that works for one country breaks on all the others.
This guide covers four approaches, from simple to production-grade, with working code for each.
The Problem with Simple Regex
Most developers start here:
// DON'T use this in production
const isPhone = /^\d{10}$/.test(input);
// Misses: +44 7911 123456, 0049 170 1234567, +91 98765 43210
This only matches exactly 10 digits. It rejects every valid international number, numbers with country codes, parentheses, dashes, or spaces. It also accepts invalid 10-digit strings that aren't real phone numbers.
Approach 1: Country-Specific Regex Patterns
Instead of one regex, use a separate pattern per country:
const patterns = {
US: /^1?([2-9]\d{2}[2-9]\d{6})$/,
GB: /^(?:44)?0?(7\d{9}|[1-9]\d{8,9})$/,
DE: /^(?:49)?0?([1-9]\d{4,13})$/,
FR: /^(?:33)?0?([1-9]\d{8})$/,
IN: /^(?:91)?0?([6-9]\d{9})$/,
};
function validatePhone(digits, country) {
const cleaned = digits.replace(/[\s\-\(\)\.\+]/g, '');
const pattern = patterns[country];
if (!pattern) return { valid: false, reason: 'Unsupported country' };
return { valid: pattern.test(cleaned) };
}
Pros: Zero dependencies, fast, works offline.
Cons: You need to maintain regex for every country. You also need the caller to specify the country.
Approach 2: Google's libphonenumber
Google maintains libphonenumber, originally built for Android. The JavaScript port is google-libphonenumber:
npm install google-libphonenumber
const { PhoneNumberUtil, PhoneNumberFormat } = require('google-libphonenumber');
const phoneUtil = PhoneNumberUtil.getInstance();
function validate(input, countryCode) {
try {
const number = phoneUtil.parse(input, countryCode);
return {
valid: phoneUtil.isValidNumber(number),
formatted: phoneUtil.format(number, PhoneNumberFormat.E164),
type: phoneUtil.getNumberType(number),
country: phoneUtil.getRegionCodeForNumber(number),
};
} catch (e) {
return { valid: false, reason: e.message };
}
}
Pros: Most accurate. Handles every country, number type, and format.
Cons: The bundle is ~1.2 MB. That's massive for a frontend app and significant for serverless functions where cold start time matters.
Approach 3: API-Based Validation
Offload the work to an API. This keeps your bundle size at zero and gets you extra data like location and line type:
async function validatePhone(input) {
const res = await fetch(
`https://datacheck.dev/api/validate?input=${encodeURIComponent(input)}&type=phone`
);
return res.json();
}
// Returns:
// {
// valid: true,
// formatted: "+1 4155551234",
// country: "US",
// details: {
// type: "mobile",
// area_code: "415",
// location: "San Francisco, CA"
// }
// }
Or use the npm wrapper:
npm install datacheck-api
import { validatePhone } from "datacheck-api";
const result = await validatePhone("+14155551234");
console.log(result.details.location); // "San Francisco, CA"
Pros: Zero bundle size. Returns enriched data (location, line type). Always up to date.
Cons: Requires network call. Depends on external service uptime.
Approach 4: Hybrid (Recommended)
The production sweet spot: validate format client-side for instant feedback, then verify server-side via API before saving:
// Client-side: quick format check
function quickCheck(input) {
const digits = input.replace(/\D/g, '');
if (digits.length < 7 || digits.length > 15) {
return { valid: false, reason: 'Phone must be 7-15 digits' };
}
return { valid: true };
}
// Server-side: full validation before saving
async function fullValidation(input) {
const res = await fetch(
`https://datacheck.dev/api/validate?input=${encodeURIComponent(input)}&type=phone`
);
const data = await res.json();
if (!data.valid) throw new Error(data.reason);
return data;
}
This gives users instant feedback on typos while catching all edge cases on the backend.
Comparison Table
| Approach | Accuracy | Bundle Size | Countries | Extra Data |
|---|---|---|---|---|
| Simple regex | Low | 0 KB | 1 | No |
| Country patterns | Medium | ~2 KB | 5-30 | No |
| libphonenumber | High | ~1.2 MB | 250+ | Type only |
| API (DataCheck) | High | 0 KB | 30+ | Type, location, area code |
| Hybrid | High | ~1 KB | 30+ | Type, location, area code |
Common Pitfalls
1. Stripping the + sign too early
The + prefix indicates international format. If you strip it before parsing, +44 becomes 44, which could be confused with a domestic number starting with 4.
2. Assuming all countries have the same length
US numbers are always 10 digits. German numbers range from 7 to 12 digits. Chinese mobile numbers are 11 digits. Never hardcode length.
3. Ignoring leading zeros
In the UK, 07911 123456 is valid locally but the leading 0 is dropped in international format: +44 7911 123456. Your validator needs to handle both.
4. Not returning formatted output
Always store the E.164 formatted version (+14155551234). This is the universal format that SMS gateways, Twilio, and phone APIs expect.
Wrapping Up
For hobby projects, country-specific regex works fine. For production apps with international users, use either libphonenumber (if bundle size isn't a concern) or an API like DataCheck (if you want zero dependencies and enriched data).
The hybrid approach gives you the best of both: instant client-side feedback and accurate server-side validation with location data.
Top comments (0)