sociallinks.dev

Blog / Developer guide

How to parse and normalize social media profile URLs (Instagram, TikTok, YouTube, X)

Creators paste their profile links in every shape imaginable: with and without www, from the mobile app with tracking parameters, as short links, as old twitter.com URLs, or as a link to a post instead of the profile. If your product stores those strings as they arrive, the same creator shows up three times and your joins quietly miss. This guide shows you how to turn any Instagram, TikTok, YouTube or X profile URL into a platform, a handle or channel ID, and one canonical URL, with tested code in JavaScript and Python.

Why normalizing profile URLs matters

A profile URL is an identifier, and identifiers only work when they are written one way. https://www.instagram.com/natgeo/, instagram.com/NatGeo?igsh=MWQ1ZGUxMzBkMA== and https://m.instagram.com/natgeo all point at the same account, but as strings they are three different values.

The goal is a small, strict function: it accepts what users actually paste, returns a normalized result for real profile URLs, and returns null for everything else.

The URL shapes you will see

The shapes below were checked against the live sites in October 2026. Platforms change their routing without notice, so treat this as a snapshot and keep your test cases current.

Instagram

TikTok

YouTube

X (formerly Twitter)

Handle character rules per platform

Validating the handle catches typos and most non-profile paths. Use the platforms' own rules where they publish them, and keep the checks loose where they do not, so you never reject a real account.

PlatformAllowed charactersLengthCaseSource
InstagramLetters, numbers, periods, underscores30 is widely cited; not found in Instagram's help pagesTreat as case-insensitive; store lowercaseInstagram Help Center mentions periods, numbers and underscores
TikTokLetters, numbers, underscores, periods; cannot end with a period2 to 24Case-insensitive in URLs (@TikTok resolves to tiktok)TikTok Support, changing your username
YouTubeLetters or numbers from supported languages, plus underscores, hyphens, periods and Latin middle dots (not first or last)3 to 30 for most scripts; shorter limits for Han, Hangul and some other scriptsNot case-sensitiveYouTube Help, handle guidelines
XLetters, numbers and underscoresUp to 15; new usernames must be longer than 4, but older short handles existNot case-sensitive (x.com/JACK canonicalizes to x.com/jack)X Help Center, username rules

A normalization algorithm

  1. Step 1Trim and add a scheme

    Strip whitespace and stray angle brackets. If the input has no scheme, prepend https:// so instagram.com/natgeo parses.

  2. Step 2Parse with a real URL parser

    Use new URL() in JavaScript or urllib.parse.urlsplit in Python, never a regex over the whole string. A parser handles ports, user info and encoded characters, and stops https://instagram.com@evil.com/natgeo from passing as Instagram.

  3. Step 3Normalize the host

    Lowercase it and strip one leading www., m. or mobile.. Then look it up in an exact allow-list of hosts. Never use includes("instagram.com"), which also matches instagram.com.evil.com.

  4. Step 4Drop the query string and fragment

    Tracking parameters such as igsh, s, t and lang never change which profile a URL points at.

  5. Step 5Split and decode the path

    Split on /, drop empty segments (this removes trailing slashes) and percent-decode each one. Treat malformed encoding as invalid input.

  6. Step 6Route by platform

    Apply each platform's shape: one segment on Instagram and X, an @ segment on TikTok, and @handle, channel/UC..., user/name or c/name on YouTube. Reject reserved words.

  7. Step 7Validate and lowercase the handle

    Check the handle against the platform's character rules, then lowercase it. Do not lowercase YouTube channel IDs: they are case-sensitive.

  8. Step 8Build the canonical URL

    Rebuild the URL from the parts on one fixed host, so every equivalent input produces exactly the same string.

  9. Step 9Handle short links separately

    Detect TikTok short links, follow the redirect with an HTTP request, and run the final URL back through the parser. Keep this out of the pure function so parsing stays fast and testable.

The code in JavaScript and Python

Both functions return the same objects for the same input: { platform, handle, canonicalUrl } for handle URLs, { platform, channelId, canonicalUrl } for YouTube channel IDs, username or customName for the two legacy YouTube forms, and null for anything that is not a profile URL. Neither needs a dependency.

JavaScript (Node 18+)

Node.js

const HOSTS = {
  "instagram.com": "instagram",
  "tiktok.com": "tiktok",
  "youtube.com": "youtube",
  "x.com": "x",
  "twitter.com": "x",
};

const IG_RESERVED = new Set(["p", "reel", "reels", "tv", "stories", "explore", "accounts", "direct"]);
const X_RESERVED = new Set([
  "home", "explore", "search", "i", "settings", "notifications", "messages",
  "compose", "intent", "hashtag", "login", "signup", "logout", "tos", "privacy",
]);

const IG_HANDLE = /^[a-z0-9._]{1,30}$/;
const TIKTOK_HANDLE = /^[a-z0-9._]{2,24}$/;
const X_HANDLE = /^[a-z0-9_]{1,15}$/;
const YT_HANDLE = /^[\p{L}\p{M}\p{N}._\-·]{1,30}$/u;
const YT_CHANNEL_ID = /^UC[A-Za-z0-9_-]{22}$/;
const YT_LEGACY_NAME = /^[A-Za-z0-9._-]{1,100}$/;

export function parseProfileUrl(input) {
  if (typeof input !== "string") return null;
  let raw = input.trim().replace(/^<|>$/g, "");
  if (!raw) return null;
  if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(raw)) raw = "https://" + raw;

  let url;
  try {
    url = new URL(raw);
  } catch {
    return null;
  }
  if (url.protocol !== "https:" && url.protocol !== "http:") return null;

  const host = url.hostname.toLowerCase().replace(/^(www|m|mobile)\./, "");
  const platform = HOSTS[host];
  if (!platform) return null;

  let segments;
  try {
    segments = url.pathname.split("/").filter(Boolean).map(decodeURIComponent);
  } catch {
    return null; // malformed percent-encoding
  }
  const [first = "", second] = segments;

  if (platform === "instagram") {
    const handle = first.toLowerCase();
    if (segments.length !== 1 || IG_RESERVED.has(handle) || !IG_HANDLE.test(handle)) return null;
    return { platform, handle, canonicalUrl: `https://www.instagram.com/${handle}/` };
  }

  if (platform === "tiktok") {
    if (segments.length !== 1 || !first.startsWith("@")) return null;
    const handle = first.slice(1).toLowerCase();
    if (!TIKTOK_HANDLE.test(handle) || handle.endsWith(".")) return null;
    return { platform, handle, canonicalUrl: `https://www.tiktok.com/@${handle}` };
  }

  if (platform === "x") {
    const handle = first.toLowerCase();
    if (segments.length !== 1 || X_RESERVED.has(handle) || !X_HANDLE.test(handle)) return null;
    return { platform, handle, canonicalUrl: `https://x.com/${handle}` };
  }

  // YouTube: /@handle[/tab], /channel/UC...[/tab], /user/name, /c/name
  if (first.startsWith("@")) {
    const handle = first.slice(1).toLowerCase();
    if (!YT_HANDLE.test(handle) || /^[._\-·]|[._\-·]$/.test(handle)) return null;
    return { platform, handle, canonicalUrl: `https://www.youtube.com/@${encodeURIComponent(handle)}` };
  }
  if (first === "channel" && second && YT_CHANNEL_ID.test(second)) {
    return { platform, channelId: second, canonicalUrl: `https://www.youtube.com/channel/${second}` };
  }
  if ((first === "user" || first === "c") && second && segments.length === 2 && YT_LEGACY_NAME.test(second)) {
    const key = first === "user" ? "username" : "customName";
    return { platform, [key]: second, canonicalUrl: `https://www.youtube.com/${first}/${second}` };
  }
  return null; // videos, shorts, playlists, search and anything else
}

const SHORT_HOSTS = new Set(["vm.tiktok.com", "vt.tiktok.com"]);

export function isShortLink(input) {
  try {
    const url = new URL(/^https?:\/\//i.test(input) ? input : "https://" + input);
    const host = url.hostname.toLowerCase();
    return SHORT_HOSTS.has(host) || (host.replace(/^www\./, "") === "tiktok.com" && url.pathname.startsWith("/t/"));
  } catch {
    return false;
  }
}

export async function resolveShortLink(input) {
  const res = await fetch(/^https?:\/\//i.test(input) ? input : "https://" + input, {
    redirect: "follow",
    signal: AbortSignal.timeout(10_000),
  });
  await res.body?.cancel();
  return res.url; // the final URL after every redirect
}

resolveShortLink uses the built-in fetch, which follows redirects and exposes the final address as res.url. Call parseProfileUrl(await resolveShortLink(link)) when isShortLink(link) is true. A short link can also lead to a video rather than a profile, in which case the parser correctly returns null.

Python 3

Python

import re
import unicodedata
import urllib.request
from urllib.parse import quote, unquote, urlsplit

HOSTS = {
    "instagram.com": "instagram",
    "tiktok.com": "tiktok",
    "youtube.com": "youtube",
    "x.com": "x",
    "twitter.com": "x",
}

IG_RESERVED = {"p", "reel", "reels", "tv", "stories", "explore", "accounts", "direct"}
X_RESERVED = {
    "home", "explore", "search", "i", "settings", "notifications", "messages",
    "compose", "intent", "hashtag", "login", "signup", "logout", "tos", "privacy",
}

IG_HANDLE = re.compile(r"[a-z0-9._]{1,30}")
TIKTOK_HANDLE = re.compile(r"[a-z0-9._]{2,24}")
X_HANDLE = re.compile(r"[a-z0-9_]{1,15}")
YT_CHANNEL_ID = re.compile(r"UC[A-Za-z0-9_-]{22}")
YT_LEGACY_NAME = re.compile(r"[A-Za-z0-9._-]{1,100}")
YT_PUNCT = "._-·"


def _is_youtube_handle(handle):
    if not 1 <= len(handle) <= 30 or handle[0] in YT_PUNCT or handle[-1] in YT_PUNCT:
        return False
    return all(
        ch.isalnum() or ch in YT_PUNCT or unicodedata.category(ch).startswith("M")
        for ch in handle
    )


def parse_profile_url(value):
    if not isinstance(value, str):
        return None
    raw = value.strip().strip("<>")
    if not raw:
        return None
    if not re.match(r"^[a-z][a-z0-9+.-]*://", raw, re.I):
        raw = "https://" + raw

    try:
        url = urlsplit(raw)
        host = (url.hostname or "").lower()
    except ValueError:
        return None
    if url.scheme.lower() not in ("http", "https"):
        return None

    host = re.sub(r"^(www|m|mobile)\.", "", host)
    platform = HOSTS.get(host)
    if not platform:
        return None

    try:
        segments = [unquote(s, errors="strict") for s in url.path.split("/") if s]
    except UnicodeDecodeError:
        return None  # malformed percent-encoding
    first = segments[0] if segments else ""
    second = segments[1] if len(segments) > 1 else None

    if platform == "instagram":
        handle = first.lower()
        if len(segments) != 1 or handle in IG_RESERVED or not IG_HANDLE.fullmatch(handle):
            return None
        return {"platform": platform, "handle": handle,
                "canonicalUrl": f"https://www.instagram.com/{handle}/"}

    if platform == "tiktok":
        if len(segments) != 1 or not first.startswith("@"):
            return None
        handle = first[1:].lower()
        if not TIKTOK_HANDLE.fullmatch(handle) or handle.endswith("."):
            return None
        return {"platform": platform, "handle": handle,
                "canonicalUrl": f"https://www.tiktok.com/@{handle}"}

    if platform == "x":
        handle = first.lower()
        if len(segments) != 1 or handle in X_RESERVED or not X_HANDLE.fullmatch(handle):
            return None
        return {"platform": platform, "handle": handle, "canonicalUrl": f"https://x.com/{handle}"}

    # YouTube: /@handle[/tab], /channel/UC...[/tab], /user/name, /c/name
    if first.startswith("@"):
        handle = first[1:].lower()
        if not _is_youtube_handle(handle):
            return None
        return {"platform": platform, "handle": handle,
                "canonicalUrl": f"https://www.youtube.com/@{quote(handle, safe='._-')}"}
    if first == "channel" and second and YT_CHANNEL_ID.fullmatch(second):
        return {"platform": platform, "channelId": second,
                "canonicalUrl": f"https://www.youtube.com/channel/{second}"}
    if first in ("user", "c") and second and len(segments) == 2 and YT_LEGACY_NAME.fullmatch(second):
        key = "username" if first == "user" else "customName"
        return {"platform": platform, key: second,
                "canonicalUrl": f"https://www.youtube.com/{first}/{second}"}
    return None  # videos, shorts, playlists, search and anything else


SHORT_HOSTS = {"vm.tiktok.com", "vt.tiktok.com"}


def is_short_link(value):
    url = urlsplit(value if re.match(r"^https?://", value, re.I) else "https://" + value)
    host = (url.hostname or "").lower()
    return host in SHORT_HOSTS or (
        re.sub(r"^www\.", "", host) == "tiktok.com" and url.path.startswith("/t/")
    )


def resolve_short_link(value, timeout=10):
    if not re.match(r"^https?://", value, re.I):
        value = "https://" + value
    request = urllib.request.Request(value, headers={"User-Agent": "Mozilla/5.0"})
    with urllib.request.urlopen(request, timeout=timeout) as response:
        return response.geturl()  # the final URL after every redirect

Python's \w and str.isalnum() cover Unicode letters and digits but not the combining marks used by scripts such as Devanagari, which is why the YouTube check also accepts characters in the Unicode M categories.

Edge cases and tests

Every row below is part of the test suite that both implementations pass.

InputOutputWhy
instagram.com/NatGeo?igsh=MWQ1ZGUxMzBkMA==instagram, natgeo, https://www.instagram.com/natgeo/No scheme, mixed case, share parameter
https://m.instagram.com/nat.geo_/#bioinstagram, nat.geo_Mobile host and fragment
https://www.instagram.com/p/C9xYzAbCdEf/nullA post, not a profile
https://www.instagram.com/stories/natgeo/1234567890/nullA story, even though the handle is in the path
tiktok.com/@Khaby.Lame/tiktok, khaby.lame, https://www.tiktok.com/@khaby.lameTrailing slash and case
https://www.tiktok.com/@khaby.lame/video/7106594312292453675nullA video URL
https://vm.tiktok.com/ZMabc123/null (follow the redirect first)Short link with no handle in it
https://m.youtube.com/@GoogleDevelopers/videos?app=desktopyoutube, googledevelopersMobile host, channel tab, query
https://www.youtube.com/channel/UC_x5XG1OV2P6uZZ5FSM9Ttw/featuredyoutube, channelId UC_x5XG1OV2P6uZZ5FSM9TtwChannel ID keeps its case
https://www.youtube.com/c/GoogleDevelopersyoutube, customName GoogleDevelopersLegacy custom URL; needs a lookup for the ID
https://www.youtube.com/@%E3%83%86%E3%82%B9%E3%83%88youtube, handle テストPercent-encoded Unicode handle
https://youtu.be/dQw4w9WgXcQnullA video, not a channel
https://mobile.twitter.com/jack?s=21&t=abcx, jack, https://x.com/jackOld domain and share parameters
https://x.com/i/flow/loginnullApp route
https://x.com/this_handle_is_too_longnullMore than 15 characters
https://instagram.com.evil.com/natgeonullLookalike host
https://instagram.com@evil.com/natgeonullUser info trick; the real host is evil.com
@natgeonullA bare handle has no platform

Keep the cases in your repository and run them in CI. When a platform adds a route or a share parameter, you add one row and the fix is obvious.

Node.js test

import assert from "node:assert/strict";
import { parseProfileUrl } from "./parse.mjs";

const cases = [
  ["instagram.com/NatGeo?igsh=MWQ1ZGUxMzBkMA==", { platform: "instagram", handle: "natgeo", canonicalUrl: "https://www.instagram.com/natgeo/" }],
  ["https://www.instagram.com/reel/C9xYzAbCdEf/", null],
  ["https://www.instagram.com/explore/", null],
  ["tiktok.com/@Khaby.Lame/", { platform: "tiktok", handle: "khaby.lame", canonicalUrl: "https://www.tiktok.com/@khaby.lame" }],
  ["https://www.tiktok.com/@khaby.lame/video/7106594312292453675", null],
  ["https://vm.tiktok.com/ZMabc123/", null],
  ["https://m.youtube.com/@GoogleDevelopers/videos", { platform: "youtube", handle: "googledevelopers", canonicalUrl: "https://www.youtube.com/@googledevelopers" }],
  ["https://www.youtube.com/channel/UC_x5XG1OV2P6uZZ5FSM9Ttw/featured", { platform: "youtube", channelId: "UC_x5XG1OV2P6uZZ5FSM9Ttw", canonicalUrl: "https://www.youtube.com/channel/UC_x5XG1OV2P6uZZ5FSM9Ttw" }],
  ["https://www.youtube.com/user/GoogleDevelopers", { platform: "youtube", username: "GoogleDevelopers", canonicalUrl: "https://www.youtube.com/user/GoogleDevelopers" }],
  ["https://youtu.be/dQw4w9WgXcQ", null],
  ["https://twitter.com/Jack/", { platform: "x", handle: "jack", canonicalUrl: "https://x.com/jack" }],
  ["https://mobile.twitter.com/jack?s=21", { platform: "x", handle: "jack", canonicalUrl: "https://x.com/jack" }],
  ["https://x.com/jack/status/20", null],
  ["https://x.com/i/flow/login", null],
  ["https://instagram.com.evil.com/natgeo", null],
];

for (const [input, expected] of cases) {
  assert.deepEqual(parseProfileUrl(input), expected, input);
}
console.log(`${cases.length} cases passed`);

Python test

from parse_profile import parse_profile_url

CASES = [
    ("instagram.com/NatGeo?igsh=MWQ1ZGUxMzBkMA==", {"platform": "instagram", "handle": "natgeo", "canonicalUrl": "https://www.instagram.com/natgeo/"}),
    ("https://www.instagram.com/reel/C9xYzAbCdEf/", None),
    ("tiktok.com/@Khaby.Lame/", {"platform": "tiktok", "handle": "khaby.lame", "canonicalUrl": "https://www.tiktok.com/@khaby.lame"}),
    ("https://vm.tiktok.com/ZMabc123/", None),
    ("https://m.youtube.com/@GoogleDevelopers/videos", {"platform": "youtube", "handle": "googledevelopers", "canonicalUrl": "https://www.youtube.com/@googledevelopers"}),
    ("https://www.youtube.com/channel/UC_x5XG1OV2P6uZZ5FSM9Ttw/featured", {"platform": "youtube", "channelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw", "canonicalUrl": "https://www.youtube.com/channel/UC_x5XG1OV2P6uZZ5FSM9Ttw"}),
    ("https://youtu.be/dQw4w9WgXcQ", None),
    ("https://twitter.com/Jack/", {"platform": "x", "handle": "jack", "canonicalUrl": "https://x.com/jack"}),
    ("https://x.com/i/flow/login", None),
    ("https://instagram.com.evil.com/natgeo", None),
]

for value, expected in CASES:
    assert parse_profile_url(value) == expected, value
print(f"{len(CASES)} cases passed")

Store the raw input and the canonical form

Normalization is lossy by design, and your rules will improve over time. Store both values:

Handles are not permanent. Creators rename accounts on every platform, and a handle you stored last year may now belong to someone else. Where an official API gives you a stable ID, such as a YouTube channel ID, store that as the long-term key and treat the handle as a display value you refresh. The companion guide on getting a YouTube channel ID from a handle shows how.

From one profile to the others

Once a profile URL is normalized, the next question is usually which other accounts belong to the same creator. sociallinks.dev is an API that takes one Instagram, TikTok, YouTube or X profile URL and returns the creator's accounts on other platforms, with a confidence score and the evidence for each match.

FAQ

Should I lowercase handles before storing them?

Yes for the lookup key. YouTube and X say handles are not case-sensitive, and TikTok resolves @TikTok to the same account as @tiktok. Instagram does not document a case rule, but usernames are displayed in lowercase, so lowercasing is the safe default. Keep the original casing in the raw URL if you want to display it. Do not lowercase YouTube channel IDs, which are case-sensitive.

Can I extract the handle from a post or video URL?

For TikTok videos (/@handle/video/id) and X posts (/handle/status/id) the handle in the path is the author, so you can extract it if that is what you want. Instagram post and reel URLs (/p/ and /reel/) do not include the handle. The functions in this guide return null for all of them because they are not profile links.

How do I handle vm.tiktok.com links?

Short links contain only a code. Make an HTTP request, follow the redirects, and parse the final URL. Do it in a separate step with a timeout and a cache, because it is a network call and the code can expire.

Is twitter.com still a valid profile URL?

Yes. twitter.com profile URLs redirect to x.com, so treat both domains as the X platform and store the x.com form as canonical.

Why not use one big regular expression?

A single regex over the full string is hard to read and easy to fool with lookalike hosts, user info or encoded characters. Parse the URL with the standard library first, then use small regexes on the handle only.

How do I turn a YouTube /c/ or /user/ URL into a channel ID?

/user/<name> URLs map to the YouTube Data API's forUsername filter. There is no API filter for /c/ custom names. See the guide on getting a YouTube channel ID from a handle for both cases.

Find a creator’s accounts