DevToolBoxGRATUIT
Blog

Générateur de Slug URL: Créer des Permaliens SEO — Guide Complet

13 min de lecturepar DevToolBox

TL;DR

A URL slug is the human-readable part of a URL — lowercase, hyphen-separated, with special characters stripped. Generate slugs in JavaScript with a single regex pipeline, or use the slugify npm package for production. For Python, use python-slugify. For multilingual content (Japanese, Chinese, Arabic), use limax for transliteration. Always enforce uniqueness at the database level and implement 301 redirects when slugs change. Keep slugs under 60 characters and remove stop words for SEO. Use our online slug generator to convert any text instantly.

What Is a URL Slug? Definition, SEO Importance, and Rules

A URL slug is the part of a URL that comes after the domain name and identifies a specific page in a human-readable format. It is the final segment of a URL path, typically derived from the page title or content subject. Consider this URL:

https://example.com/blog/how-to-create-seo-friendly-url-slugs
                      ↑                  ↑
                   base path           slug

The slug how-to-create-seo-friendly-url-slugs is the identifier. Slugs originated in publishing as a short label for an article before it was published. Web frameworks adopted the term for URL segments.

SEO Importance

URL slugs are a lightweight but real ranking factor. Google reads the URL to understand what a page is about before it even reads the content. A slug containing your target keyword signals strong topical relevance. Additionally:

  • URLs appear in SERPs (search engine results pages) — clean slugs get higher click-through rates
  • Shared URLs (social media, email) are more trustworthy when slugs are readable
  • Anchor text in backlinks often copies the URL — a keyword-rich slug helps
  • Google has confirmed that hyphens separate words in slugs (underscores do not)

Slug Rules

RuleCorrectWrong
Lowercase onlymy-blog-postMy-Blog-Post
Hyphens as separatorsurl-slug-guideurl_slug_guide
No special characterscafe-au-laitcafé-au-lait
No leading/trailing hyphensseo-best-practices-seo-best-practices-
Under 60 charactersjavascript-slug-generatorthe-complete-and-very-long-guide-to-creating-url-slugs-for-seo
No consecutive hyphensc-plus-plus-guidec--plus--plus--guide

JavaScript Slug Generator — Comprehensive Function with Unicode Support

The following function handles the full range of slug generation requirements: Unicode normalization, accent removal, special character stripping, consecutive hyphen collapsing, and trim. It works in both Node.js and modern browsers with zero dependencies.

/**
 * Comprehensive URL slug generator
 * Handles Unicode, accents, special chars, consecutive hyphens
 * @param {string} text - Input text to slugify
 * @param {object} options - Configuration options
 * @returns {string} - URL-safe slug
 */
function generateSlug(text, options = {}) {
  const {
    separator = '-',
    lowercase = true,
    maxLength = 0,     // 0 = no limit
    stopWords = [],    // words to remove e.g. ['a', 'an', 'the']
    strict = false,    // only allow [a-z0-9] and separator if true
  } = options;

  if (!text || typeof text !== 'string') return '';

  let slug = text
    // Step 1: Normalize Unicode — decompose accented characters
    // e.g. "é" → "é" (e + combining acute accent)
    .normalize('NFD')

    // Step 2: Remove combining diacritical marks (accents)
    // Unicode range U+0300–U+036F = combining marks
    .replace(/[̀-ͯ]/g, '')

    // Step 3: Handle common special characters explicitly
    .replace(/[&]/g, ' and ')
    .replace(/[@]/g, ' at ')
    .replace(/[%]/g, ' percent ')
    .replace(/[+]/g, ' plus ')
    .replace(/[=]/g, ' equals ')

    // Step 4: Convert to lowercase
    .toLowerCase()

    // Step 5: Remove stop words if provided
    // Wrap in word boundaries to avoid partial matches
    // (only applied if stopWords array is non-empty)
    .replace(
      stopWords.length
        ? new RegExp('\b(' + stopWords.join('|') + ')\b', 'gi')
        : /(?!)/,
      ''
    );

  if (strict) {
    // Strict mode: only keep a-z, 0-9, and whitespace
    slug = slug.replace(/[^a-z0-9s]/g, ' ');
  } else {
    // Normal mode: keep letters, numbers, whitespace; remove everything else
    slug = slug.replace(/[^ws-]/g, ' ');
  }

  slug = slug
    // Step 6: Replace whitespace and underscores with separator
    .replace(/[s_]+/g, separator)

    // Step 7: Collapse consecutive separators into one
    .replace(new RegExp(separator.replace(/[-]/g, '\-') + '+', 'g'), separator)

    // Step 8: Trim leading and trailing separators
    .replace(new RegExp('^' + separator.replace(/[-]/g, '\-') + '+|' + separator.replace(/[-]/g, '\-') + '+$', 'g'), '');

  // Step 9: Optional max length (truncate at word boundary)
  if (maxLength > 0 && slug.length > maxLength) {
    slug = slug.substring(0, maxLength);
    // Trim trailing separator if we cut mid-word
    slug = slug.replace(new RegExp(separator.replace(/[-]/g, '\-') + '+$'), '');
  }

  return slug;
}

// Examples:
console.log(generateSlug('Hello World!'));
// → "hello-world"

console.log(generateSlug('Hello World — Unicode Text'));
// → "hello-world-unicode-text"

console.log(generateSlug('C++ Programming & Data Structures'));
// → "c-programming-and-data-structures"

console.log(generateSlug('The Complete Guide to URL Slugs', {
  stopWords: ['a', 'an', 'the', 'to'],
  maxLength: 50,
}));
// → "complete-guide-url-slugs"

console.log(generateSlug('100% Free @ Home + Garden = Life!'));
// → "100-percent-free-at-home-plus-garden-equals-life"

console.log(generateSlug('  ---Hello   World---  '));
// → "hello-world"

// TypeScript version:
function generateSlugTS(
  text: string,
  options: {
    separator?: string;
    lowercase?: boolean;
    maxLength?: number;
    stopWords?: string[];
    strict?: boolean;
  } = {}
): string {
  const { separator = '-', maxLength = 0, stopWords = [], strict = false } = options;

  if (!text) return '';

  let slug = text
    .normalize('NFD')
    .replace(/[̀-ͯ]/g, '')
    .replace(/[&]/g, ' and ')
    .replace(/[@]/g, ' at ')
    .toLowerCase();

  if (strict) {
    slug = slug.replace(/[^a-z0-9s]/g, ' ');
  } else {
    slug = slug.replace(/[^ws-]/g, ' ');
  }

  if (stopWords.length) {
    slug = slug.replace(new RegExp('\b(' + stopWords.join('|') + ')\b', 'gi'), '');
  }

  slug = slug
    .replace(/[s_]+/g, separator)
    .replace(/-+/g, separator)
    .replace(/^-+|-+$/g, '');

  if (maxLength > 0 && slug.length > maxLength) {
    slug = slug.substring(0, maxLength).replace(/-+$/, '');
  }

  return slug;
}

Using the slugify npm Library — Options, Custom Replacements, and Locale

The slugify package is the most widely used slug library in the Node.js ecosystem with over 10 million weekly downloads. It handles accent removal, Unicode normalization, and provides a clean API with sensible defaults.

# Install
npm install slugify

# With TypeScript types (included in package)
npm install slugify
import slugify from 'slugify';
// Or CommonJS:
// const slugify = require('slugify');

// Basic usage — converts to lowercase and replaces spaces with hyphens
slugify('Hello World');
// → 'Hello-World'   (note: lowercase not applied by default!)

// Always use { lower: true } for SEO-friendly slugs
slugify('Hello World', { lower: true });
// → 'hello-world'

// ===== ALL OPTIONS =====
slugify('My String Value!', {
  replacement: '-',        // replace spaces with this character (default: '-')
  remove: undefined,       // regex to remove characters (e.g. /[*+~.()'"!:@]/g)
  lower: true,             // convert to lowercase (default: false)
  strict: false,           // strip special characters except replacement (default: false)
  locale: 'de',            // language for character mappings (default: system)
  trim: true,              // trim leading/trailing replacement chars (default: true)
});

// ===== STRICT MODE =====
// strict: true only keeps [a-z], [0-9], and the replacement character
slugify('Hello@World #2024!', { lower: true, strict: true });
// → 'helloworld-2024'

// ===== CUSTOM CHARACTER REPLACEMENTS =====
// Extend the default character map with your own mappings
slugify.extend({
  '♥': 'love',     // ♥ → love
  '©': 'c',         // © → c
  '™': 'tm',        // ™ → tm
  '€': 'euro',      // € → euro
  '£': 'pound',     // £ → pound
  '$': 'dollar',    // $ → dollar
  '¥': 'yen',       // ¥ → yen
  '★': 'star',      // ★ → star
  '→': 'to',        // → → to
  '≥': 'gte',       // ≥ → gte
  '≤': 'lte',       // ≤ → lte
});

slugify('I ♥ JavaScript €100', { lower: true });
// → 'i-love-javascript-euro100'

// ===== LOCALE SUPPORT =====
// German: ü → ue, ö → oe, ä → ae, ß → ss
slugify('Schöne Grüße', { lower: true, locale: 'de' });
// → 'schone-gruse'   (without locale: 'de')
// → 'schoene-gruesse' (with locale: 'de') — more accurate German

// Turkish: ı → i, ğ → g, ş → s, ç → c, ö → o, ü → u
slugify('Merhaba Dünya', { lower: true, locale: 'tr' });
// → 'merhaba-dunya'

// Vietnamese: handles tonal diacritics
slugify('Thành phố Hồ Chí Minh', { lower: true, locale: 'vi' });
// → 'thanh-pho-ho-chi-minh'

// ===== REMOVE SPECIFIC CHARACTERS =====
// Remove characters matching a regex
slugify('Hello.World (2024) [v2]', {
  lower: true,
  remove: /[.*+~()'"!:@[]]/g,
});
// → 'hello-world-2024-v2'

// ===== REAL-WORLD EXAMPLE: Blog Post Slug =====
function createBlogSlug(title: string): string {
  return slugify(title, {
    lower: true,
    strict: true,
    remove: /[*+~.()'"!:@#$%^&]/g,
    trim: true,
  });
}

createBlogSlug('Top 10 JavaScript Tips & Tricks (2024)');
// → 'top-10-javascript-tips-tricks-2024'

createBlogSlug('What's New in React 19?');
// → 'whats-new-in-react-19'

Python Slug Generation — python-slugify, Custom Stopwords, max_length

Python has several slugify libraries. The most full-featured is python-slugify, which usestext-unidecode (or optionally unidecode) for transliteration and supports stopwords, max length, and custom separators.

# Install
pip install python-slugify

# Optional: better transliteration for non-Latin scripts
pip install python-slugify[unidecode]
from slugify import slugify

# Basic usage
slugify('Hello World')
# → 'hello-world'

slugify('Héllo Wörld')
# → 'hello-world'

# ===== ALL PARAMETERS =====
slugify(
    'My Title String!',
    entities=True,           # decode HTML entities (& → and)
    decimal=True,            # decode decimal HTML entities (A → a)
    hexadecimal=True,        # decode hex HTML entities (A → a)
    max_length=0,            # truncate to this length (0 = no limit)
    word_boundary=False,     # truncate at word boundary if True
    save_order=False,        # maintain word order after stopword removal
    separator='-',           # character between words
    stopwords=(),            # tuple/list of words to remove
    regex_pattern=None,      # custom regex for character replacement
    lowercase=True,          # convert to lowercase
    replacements=(),         # list of [original, replacement] pairs
    allow_unicode=False,     # allow unicode characters in output
)

# ===== STOPWORDS =====
slugify('The Complete Guide to Python Programming',
        stopwords=['the', 'to', 'a', 'an', 'of', 'in', 'and', 'or'])
# → 'complete-guide-python-programming'

slugify('A Journey Through Time and Space',
        stopwords=['a', 'and', 'through'])
# → 'journey-time-space'

# ===== MAX LENGTH WITH WORD BOUNDARY =====
# Without word_boundary: truncates exactly at max_length chars
slugify('This is a very long title that exceeds the limit',
        max_length=30)
# → 'this-is-a-very-long-title-tha'  (cuts mid-word!)

# With word_boundary: truncates at the nearest word boundary
slugify('This is a very long title that exceeds the limit',
        max_length=30,
        word_boundary=True)
# → 'this-is-a-very-long-title'  (clean word boundary)

# ===== CUSTOM REPLACEMENTS =====
# List of [from, to] pairs applied before slugification
slugify('C++ Programming & Web Development',
        replacements=[
            ['C++', 'cpp'],
            ['&', 'and'],
        ])
# → 'cpp-programming-and-web-development'

# ===== ALLOW UNICODE (for CJK or Cyrillic slugs) =====
slugify('Привет мир', allow_unicode=True)
# → 'привет-мир'   (Cyrillic preserved)

# ===== CUSTOM REGEX =====
import re
slugify('Hello---World   (2024)',
        regex_pattern=re.compile(r'[^a-z0-9]+'))
# → 'hello-world-2024'

# ===== PRACTICAL: Django/Flask slug field =====
from slugify import slugify as python_slugify

def create_unique_slug(title: str, existing_slugs: list[str]) -> str:
    """Generate a unique slug, appending a number if needed."""
    base = python_slugify(title, max_length=60, word_boundary=True)
    if base not in existing_slugs:
        return base
    counter = 2
    while f'{base}-{counter}' in existing_slugs:
        counter += 1
    return f'{base}-{counter}'

# Django model usage:
from django.db import models
from slugify import slugify as python_slugify

class Article(models.Model):
    title = models.CharField(max_length=200)
    slug = models.SlugField(unique=True, max_length=70)

    def save(self, *args, **kwargs):
        if not self.slug:
            base_slug = python_slugify(self.title, max_length=60, word_boundary=True)
            slug = base_slug
            n = 2
            while Article.objects.filter(slug=slug).exists():
                slug = f'{base_slug}-{n}'
                n += 1
            self.slug = slug
        super().save(*args, **kwargs)

Next.js and Express URL Routing with Slugs — Dynamic Routes and getStaticPaths

Slugs are the backbone of dynamic routing in modern web frameworks. Here is how to use slugs in both Next.js (App Router and Pages Router) and Express.js.

Next.js App Router (Next.js 13+)

// File: src/app/blog/[slug]/page.tsx
// URL: /blog/my-post-slug

import { notFound } from 'next/navigation';
import { getPostBySlug, getAllPosts } from '@/lib/posts';

interface PageProps {
  params: { slug: string };
}

// Generate static pages for all known slugs (SSG)
export async function generateStaticParams() {
  const posts = await getAllPosts();
  return posts.map((post) => ({
    slug: post.slug,
  }));
}

// Generate dynamic metadata per page
export async function generateMetadata({ params }: PageProps) {
  const post = await getPostBySlug(params.slug);
  if (!post) return {};

  return {
    title: post.title,
    description: post.excerpt,
    alternates: {
      canonical: `https://example.com/blog/${params.slug}`,
    },
    openGraph: {
      title: post.title,
      url: `https://example.com/blog/${params.slug}`,
    },
  };
}

// Page component
export default async function BlogPost({ params }: PageProps) {
  const post = await getPostBySlug(params.slug);

  if (!post) {
    notFound(); // Returns 404 page
  }

  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  );
}

// File: src/app/blog/[slug]/not-found.tsx
export default function NotFound() {
  return <div>Post not found. It may have moved.</div>;
}

// ===== CATCH-ALL SLUG (nested paths) =====
// File: src/app/docs/[...slug]/page.tsx
// Matches: /docs/api, /docs/api/auth, /docs/api/auth/tokens

interface CatchAllProps {
  params: { slug: string[] }; // Array of path segments
}

export default function DocsPage({ params }: CatchAllProps) {
  const slugPath = params.slug.join('/');
  // For /docs/api/auth → slugPath = 'api/auth'
  return <div>Docs: {slugPath}</div>;
}

Next.js Pages Router with getStaticPaths

// File: pages/blog/[slug].tsx

import { GetStaticPaths, GetStaticProps } from 'next';
import { getPostBySlug, getAllPosts } from '@/lib/posts';

interface Post {
  title: string;
  slug: string;
  content: string;
}

interface Props {
  post: Post;
}

export const getStaticPaths: GetStaticPaths = async () => {
  const posts = await getAllPosts();

  return {
    paths: posts.map((post) => ({
      params: { slug: post.slug },
    })),
    fallback: 'blocking', // ISR: generate new pages on demand
    // fallback: false     → 404 for unknown slugs (good for finite content)
    // fallback: true      → show loading state, then generate
    // fallback: 'blocking'→ wait for generation, then serve (recommended)
  };
};

export const getStaticProps: GetStaticProps<Props> = async ({ params }) => {
  const slug = params?.slug as string;
  const post = await getPostBySlug(slug);

  if (!post) {
    return { notFound: true }; // Returns 404
  }

  return {
    props: { post },
    revalidate: 3600, // ISR: regenerate every hour
  };
};

export default function BlogPost({ post }: Props) {
  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  );
}

Express.js Slug Routes

import express from 'express';
import { PrismaClient } from '@prisma/client';

const router = express.Router();
const prisma = new PrismaClient();

// GET /blog/:slug — fetch post by slug
router.get('/blog/:slug', async (req, res) => {
  const { slug } = req.params;

  // Validate slug format (security: prevent injection)
  if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug)) {
    return res.status(400).json({ error: 'Invalid slug format' });
  }

  try {
    const post = await prisma.post.findUnique({
      where: { slug },
    });

    if (!post) {
      // Check if there's a redirect
      const redirect = await prisma.slugRedirect.findUnique({
        where: { oldSlug: slug },
      });

      if (redirect) {
        return res.redirect(301, `/blog/${redirect.newSlug}`);
      }

      return res.status(404).json({ error: 'Post not found' });
    }

    res.json(post);
  } catch (error) {
    res.status(500).json({ error: 'Internal server error' });
  }
});

// POST /blog — create post with auto-generated slug
router.post('/blog', async (req, res) => {
  const { title, content } = req.body;

  // Generate slug from title
  const baseSlug = generateSlug(title, { maxLength: 60, stopWords: ['a', 'an', 'the'] });

  // Ensure uniqueness
  const slug = await generateUniqueSlug(baseSlug);

  const post = await prisma.post.create({
    data: { title, content, slug },
  });

  res.status(201).json(post);
});

// Utility: find unique slug
async function generateUniqueSlug(baseSlug: string): Promise<string> {
  let slug = baseSlug;
  let counter = 2;

  while (await prisma.post.findUnique({ where: { slug } })) {
    slug = `${baseSlug}-${counter}`;
    counter++;
  }

  return slug;
}

Handling Slug Conflicts — Uniqueness in Databases and Auto-Increment Suffix

Slug conflicts occur when two pieces of content generate the same slug — for example, two articles titled “JavaScript Tips” and “JavaScript Tips!” both produce javascript-tips. The standard solution is to append an incrementing numeric suffix.

// Prisma schema: enforce uniqueness at DB level
// schema.prisma
model Post {
  id        Int      @id @default(autoincrement())
  title     String
  slug      String   @unique   // ← DB-level UNIQUE constraint
  content   String
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  // For redirect tracking
  slugHistory SlugHistory[]
}

model SlugHistory {
  id      Int    @id @default(autoincrement())
  oldSlug String @unique
  postId  Int
  post    Post   @relation(fields: [postId], references: [id])
}

// ===== APPROACH 1: Simple loop (best for low-concurrency) =====
async function generateUniqueSlug(
  prisma: PrismaClient,
  baseSlug: string,
  excludeId?: number  // exclude current post when updating
): Promise<string> {
  let slug = baseSlug;
  let counter = 2;

  while (true) {
    const existing = await prisma.post.findFirst({
      where: {
        slug,
        ...(excludeId ? { id: { not: excludeId } } : {}),
      },
    });

    if (!existing) break; // slug is available

    slug = `${baseSlug}-${counter}`;
    counter++;
  }

  return slug;
}

// Usage:
const slug = await generateUniqueSlug(prisma, 'javascript-tips');
// If 'javascript-tips' and 'javascript-tips-2' exist:
// → 'javascript-tips-3'

// ===== APPROACH 2: Count-based (fewer DB queries) =====
async function generateUniqueSlugFast(
  prisma: PrismaClient,
  baseSlug: string
): Promise<string> {
  // Find all slugs starting with the base slug
  const existing = await prisma.post.findMany({
    where: {
      slug: { startsWith: baseSlug },
    },
    select: { slug: true },
  });

  if (existing.length === 0) return baseSlug;

  const existingSlugs = new Set(existing.map((p) => p.slug));

  if (!existingSlugs.has(baseSlug)) return baseSlug;

  // Find the next available number
  let counter = 2;
  while (existingSlugs.has(`${baseSlug}-${counter}`)) {
    counter++;
  }

  return `${baseSlug}-${counter}`;
}

// ===== APPROACH 3: Raw SQL with advisory lock (high concurrency) =====
// For production systems with high concurrent post creation:
async function generateUniqueSlugAtomic(
  prisma: PrismaClient,
  baseSlug: string
): Promise<string> {
  // PostgreSQL: use WITH RECURSIVE to find next available slug atomically
  const result = await prisma.$queryRaw<[{ slug: string }]>`
    WITH RECURSIVE slugs AS (
      SELECT slug FROM posts WHERE slug LIKE ${baseSlug + '%'}
    ),
    numbered AS (
      SELECT ${baseSlug}::text AS candidate, 1 AS n
      UNION ALL
      SELECT ${baseSlug} || '-' || (n + 1)::text, n + 1
      FROM numbered
      WHERE candidate IN (SELECT slug FROM slugs)
      AND n < 1000
    )
    SELECT candidate AS slug FROM numbered
    WHERE candidate NOT IN (SELECT slug FROM slugs)
    LIMIT 1
  `;

  return result[0]?.slug ?? baseSlug;
}

// ===== UPDATE SLUG WITH HISTORY (for redirects) =====
async function updatePostSlug(
  prisma: PrismaClient,
  postId: number,
  newTitle: string
): Promise<string> {
  const post = await prisma.post.findUnique({ where: { id: postId } });
  if (!post) throw new Error('Post not found');

  const newBaseSlug = generateSlug(newTitle, { maxLength: 60 });

  if (newBaseSlug === post.slug) return post.slug; // no change

  const newSlug = await generateUniqueSlug(prisma, newBaseSlug, postId);

  // Update with history tracking (transaction)
  await prisma.$transaction([
    prisma.slugHistory.upsert({
      where: { oldSlug: post.slug },
      create: { oldSlug: post.slug, postId },
      update: {}, // already exists, no action
    }),
    prisma.post.update({
      where: { id: postId },
      data: { slug: newSlug },
    }),
  ]);

  return newSlug;
}

Multilingual Slugs — Japanese, Chinese, Arabic Transliteration with limax and unicode-slug

When content is in non-Latin scripts, generating slugs requires transliteration — converting characters to their Latin phonetic equivalents. The standard approach uses the limax library for Node.js, which supports 100+ languages including CJK (Chinese, Japanese, Korean), Arabic, Hebrew, Cyrillic, and more.

npm install limax unicode-slug
import limax from 'limax';
import unicodeSlug from 'unicode-slug';

// ===== limax: Best for CJK + multilingual =====

// Japanese — converts kanji/kana to romaji
limax('日本語のタイトル');
// → 'ri-ben-yu-notaitoru'

limax('東京都', { lang: 'ja' });
// → 'dong-jing-du'   (using Chinese reading of kanji — specify lang for accuracy)

limax('東京都', { lang: 'ja', tone: false });
// → 'dongjingdu'

// Chinese — converts to pinyin
limax('中文标题示例');
// → 'zhong-wen-biao-ti-shi-li'

limax('你好世界', { lang: 'zh' });
// → 'ni-hao-shi-jie'

// Korean — converts to romanized Hangul
limax('한국어 제목');
// → 'hangugeo-jemog'

// Arabic — transliterates to Latin
limax('مرحبا بالعالم');
// → 'mrhba-balealm'

// Russian — Cyrillic to Latin
limax('Привет мир');
// → 'privet-mir'

// Greek
limax('Γεια σου κόσμε');
// → 'geia-sou-kosme'

// ===== unicode-slug: Lighter alternative =====
// Less comprehensive but faster, good for European languages + some CJK
unicodeSlug('Héllo Wörld');
// → 'hello-world'

unicodeSlug('日本語');
// → 'ri-ben-yu'  (basic CJK support)

// ===== PRACTICAL: Language-aware slug function =====
interface SlugOptions {
  lang?: string;
  maxLength?: number;
  separator?: string;
}

async function generateMultilingualSlug(
  text: string,
  options: SlugOptions = {}
): Promise<string> {
  const { lang = 'en', maxLength = 60, separator = '-' } = options;

  // Detect if text contains non-Latin characters
  const hasNonLatin = /[^-~]/.test(text);

  let slug: string;

  if (hasNonLatin) {
    // Use limax for transliteration
    slug = limax(text, {
      lang: lang as string,
      separator,
      tone: false,      // exclude tone marks from pinyin
      trim: true,
    });
  } else {
    // Pure ASCII — use simple approach
    slug = text
      .toLowerCase()
      .replace(/[^a-z0-9]+/g, separator)
      .replace(new RegExp(`^${separator}|${separator}$`, 'g'), '');
  }

  // Apply max length at word boundary
  if (maxLength > 0 && slug.length > maxLength) {
    slug = slug.substring(0, maxLength).replace(/-+$/, '');
  }

  return slug;
}

// Examples:
await generateMultilingualSlug('JavaScript Tutorial', { lang: 'en' });
// → 'javascript-tutorial'

await generateMultilingualSlug('JavaScriptチュートリアル', { lang: 'ja' });
// → 'javascriptchiyutoriaru'

await generateMultilingualSlug('JavaScript教程', { lang: 'zh' });
// → 'javascript-jiao-cheng'

await generateMultilingualSlug('دليل JavaScript', { lang: 'ar' });
// → 'dlyl-javascript'

// ===== ALLOW UNICODE SLUGS (modern approach) =====
// Modern browsers and servers support Unicode in URLs (percent-encoded)
// This preserves the original script — better UX for native speakers

function generateUnicodeSlug(text: string): string {
  return text
    .toLowerCase()
    .trim()
    // Only remove truly special chars — keep Unicode letters/numbers
    .replace(/[^p{L}p{N}s-]/gu, '')
    .replace(/s+/g, '-')
    .replace(/-+/g, '-')
    .replace(/^-|-$/g, '');
}

generateUnicodeSlug('مرحبا بالعالم');
// → 'مرحبا-بالعالم'   (Arabic preserved, URL-encoded when transmitted)

generateUnicodeSlug('中文标题');
// → '中文标题'   (Chinese preserved)

// Note: These display correctly in browsers but look like %E4%B8%AD%E6%96%87...
// in server logs. Choose transliteration vs Unicode based on your audience.

Go Slug Generation — github.com/gosimple/slug, MakeSlug, and AddSub

Go has a well-maintained slug library at github.com/gosimple/slug. It handles Unicode transliteration, provides language-specific substitutions, and supports custom character mappings.

# Install
go get github.com/gosimple/slug
package main

import (
    "fmt"
    "strings"
    "regexp"
    "github.com/gosimple/slug"
)

func main() {
    // ===== Basic slug generation =====
    fmt.Println(slug.Make("Hello World"))
    // → "hello-world"

    fmt.Println(slug.Make("Héllo Wörld"))
    // → "hello-world"

    fmt.Println(slug.Make("C++ Programming & Data Structures"))
    // → "c-programming-and-data-structures"

    // ===== MakeLang: language-specific substitutions =====
    // German: ü → ue, ö → oe, ä → ae, ß → ss
    fmt.Println(slug.MakeLang("Schöne Grüße", "de"))
    // → "schoene-gruesse"

    // Swedish: å → aa, ä → ae, ö → oe
    fmt.Println(slug.MakeLang("Öppen källkod", "sv"))
    // → "oppen-kallkod"

    // Turkish: ı → i, ğ → g, ş → s, ç → c
    fmt.Println(slug.MakeLang("Merhaba Dünya", "tr"))
    // → "merhaba-dunya"

    // ===== AddSub: add custom substitutions =====
    // Add language-specific or domain-specific character mappings
    // AddSub(character string, sub string, lang string)
    slug.AddSub("♥", "love", "en")
    slug.AddSub("→", "to", "en")
    slug.AddSub("≥", "gte", "en")
    slug.AddSub("€", "euro", "en")
    slug.AddSub("£", "pound", "en")

    fmt.Println(slug.Make("I ♥ Go → Fast Code ≥ €1000"))
    // → "i-love-go-to-fast-code-gte-euro1000"

    // German-specific
    slug.AddSub("ü", "ue", "de")
    slug.AddSub("ö", "oe", "de")
    slug.AddSub("ä", "ae", "de")
    slug.AddSub("ß", "ss", "de")

    fmt.Println(slug.MakeLang("Brücke über den Rhein", "de"))
    // → "bruecke-ueber-den-rhein"

    // ===== Custom separator =====
    slug.CustomSub = map[string]string{
        " ": "_",  // use underscore (not recommended for SEO)
    }
    // Note: Reset after custom use:
    slug.CustomSub = nil

    // ===== IsSlug: validate an existing slug =====
    fmt.Println(slug.IsSlug("hello-world"))      // true
    fmt.Println(slug.IsSlug("Hello World"))       // false (uppercase + space)
    fmt.Println(slug.IsSlug("hello--world"))      // false (double hyphen)
    fmt.Println(slug.IsSlug("-hello-world"))      // false (leading hyphen)

    // ===== Unique slug generator in Go =====
    fmt.Println(makeUniqueSlug("JavaScript Tips", []string{}))
    // → "javascript-tips"

    fmt.Println(makeUniqueSlug("JavaScript Tips", []string{"javascript-tips", "javascript-tips-2"}))
    // → "javascript-tips-3"
}

// makeUniqueSlug generates a slug unique within existingSlugs
func makeUniqueSlug(title string, existingSlugs []string) string {
    baseSlug := slug.Make(title)

    // Build set for O(1) lookup
    existing := make(map[string]bool, len(existingSlugs))
    for _, s := range existingSlugs {
        existing[s] = true
    }

    if !existing[baseSlug] {
        return baseSlug
    }

    for i := 2; i < 10000; i++ {
        candidate := fmt.Sprintf("%s-%d", baseSlug, i)
        if !existing[candidate] {
            return candidate
        }
    }

    return baseSlug // fallback (should never reach here)
}

// slugifyAndTruncate: slug with max length at word boundary
func slugifyAndTruncate(title string, maxLen int) string {
    s := slug.Make(title)
    if len(s) <= maxLen {
        return s
    }

    // Truncate at word boundary
    truncated := s[:maxLen]
    lastHyphen := strings.LastIndex(truncated, "-")
    if lastHyphen > 0 {
        truncated = truncated[:lastHyphen]
    }

    return truncated
}

// validateSlug: ensure a slug matches strict format
var slugRegex = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)

func validateSlug(s string) bool {
    if len(s) == 0 || len(s) > 100 {
        return false
    }
    return slugRegex.MatchString(s)
}

SEO Best Practices — Max Length, Stop Words, Canonical URLs

Slug SEO optimization goes beyond just converting text. Strategic decisions about length, keyword inclusion, and canonical URL management have measurable ranking impact.

Length Guidelines

LengthAssessmentExample
3–30 charsIdealjavascript-promises
31–60 charsAcceptablecomplete-guide-javascript-promises-async-await
61–100 charsToo longthe-complete-definitive-guide-to-understanding-javascript-promises-async-await-2024
// ===== SEO STOP WORDS (commonly removed from slugs) =====
const SEO_STOP_WORDS = [
  // Articles
  'a', 'an', 'the',
  // Prepositions
  'in', 'on', 'at', 'by', 'for', 'with', 'about', 'against',
  'between', 'into', 'through', 'during', 'before', 'after',
  'above', 'below', 'from', 'up', 'down', 'of', 'off', 'over', 'under',
  // Conjunctions
  'and', 'but', 'or', 'nor', 'so', 'yet',
  // Pronouns
  'i', 'me', 'my', 'myself', 'we', 'our', 'you', 'your',
  // Common verbs (in titles)
  'is', 'are', 'was', 'were', 'be', 'been', 'being',
  'have', 'has', 'had', 'do', 'does', 'did',
  // Other fillers
  'to', 'this', 'that', 'these', 'those',
];

function seoSlug(title: string): string {
  return generateSlug(title, {
    stopWords: SEO_STOP_WORDS,
    maxLength: 60,
    lowercase: true,
  });
}

seoSlug('The Complete Guide to Understanding JavaScript Promises');
// → 'complete-guide-understanding-javascript-promises'
// (removed: the, to)

seoSlug('A Beginner's Introduction to React and Next.js Development');
// → 'beginners-introduction-react-nextjs-development'
// (removed: a, to, and)

// ===== CANONICAL URL PATTERNS =====
// Always add canonical to prevent duplicate content

// In Next.js App Router metadata:
export const metadata = {
  alternates: {
    canonical: 'https://example.com/blog/javascript-promises',
  },
};

// Trailing slash consistency — pick one and stick with it:
// https://example.com/blog/slug    ← no trailing slash (most common)
// https://example.com/blog/slug/   ← trailing slash

// In Next.js next.config.js — enforce trailing slash:
/** @type {import('next').NextConfig} */
const nextConfig = {
  trailingSlash: false,  // false = no trailing slash (default)
  // trailingSlash: true  // true = always add trailing slash
};

// ===== SLUG VS QUERY PARAMETER =====
// ✅ SEO-friendly (slug in path):
// https://example.com/blog/javascript-promises

// ❌ Not SEO-friendly (id in query string):
// https://example.com/blog?id=12345

// ❌ Avoid numeric-only slugs — no keyword value:
// https://example.com/blog/12345

// ✅ Best practice: slug only, no numeric ID in URL:
// https://example.com/blog/javascript-promises

// ===== URL STRUCTURE DEPTH =====
// Google recommends keeping URLs shallow (2-3 path segments):
// ✅ /blog/javascript-promises
// ✅ /docs/api/auth
// ❌ /blog/category/subcategory/2024/01/javascript-promises  (too deep)

// ===== REDIRECT CHAIN AVOIDANCE =====
// Never chain redirects: A → B → C (loses PageRank with each hop)
// Always point old slug directly to the current canonical slug:
// ✅ /old-slug → /current-slug (301)
// ❌ /old-slug → /intermediate-slug → /current-slug (chain!)

WordPress and CMS Patterns — sanitize_title(), Custom Post Types, Redirect Handling

WordPress has a mature slug generation system built in. Understanding how it works is essential for WordPress developers and useful as a reference for other CMS implementations.

<?php
// ===== WordPress Core: sanitize_title() =====
// WordPress's built-in function for generating slugs
// Hooks: 'sanitize_title' filter allows customization

$title = 'Hello World! This Is My Post';
$slug  = sanitize_title($title);
// → 'hello-world-this-is-my-post'

// sanitize_title_with_dashes() — the underlying function
$slug = sanitize_title_with_dashes($title, null, 'save');
// → 'hello-world-this-is-my-post'

// The third parameter controls behavior:
// 'save'    → used when saving to DB (more aggressive stripping)
// 'display' → used for display purposes (more lenient)

// ===== Custom slug for new posts =====
// Hook: 'wp_insert_post_data' — runs before post is saved

add_filter('wp_insert_post_data', function($data, $postarr) {
    // Only for new posts without an explicit slug
    if (empty($postarr['ID']) && empty($data['post_name'])) {
        $base_slug = sanitize_title($data['post_title']);

        // Ensure uniqueness using WordPress's built-in function
        $unique_slug = wp_unique_post_slug(
            $base_slug,
            $postarr['ID'] ?? 0,
            $data['post_status'],
            $data['post_type'],
            $data['post_parent'] ?? 0
        );

        $data['post_name'] = $unique_slug;
    }
    return $data;
}, 10, 2);

// ===== Custom Post Type with custom slug prefix =====
// Register CPT with a custom rewrite slug

function register_product_cpt() {
    register_post_type('product', [
        'labels'      => ['name' => 'Products', 'singular_name' => 'Product'],
        'public'      => true,
        'has_archive' => true,
        'rewrite'     => [
            'slug'       => 'products',       // URL prefix: /products/product-name
            'with_front' => false,            // don't prepend blog base
            'feeds'      => true,
            'pages'      => true,
        ],
        'supports'    => ['title', 'editor', 'thumbnail', 'custom-fields'],
    ]);
}
add_action('init', 'register_product_cpt');

// Individual product URL: /products/blue-running-shoes
// Archive URL:           /products/

// ===== Redirect old slugs (after slug change) =====
// WordPress stores old slugs in postmeta as '_wp_old_slug'
// and handles redirects automatically via redirect_canonical()

// To manually redirect old slugs:
add_action('template_redirect', function() {
    global $wp_query;

    if ($wp_query->is_404()) {
        $requested_slug = get_query_var('name')
            ?: sanitize_title(basename($_SERVER['REQUEST_URI']));

        // Search in old slug history
        $post = get_page_by_path($requested_slug, OBJECT, ['post', 'page', 'product']);

        if (!$post) {
            // Check _wp_old_slug postmeta
            $args = [
                'post_type'  => 'any',
                'meta_key'   => '_wp_old_slug',
                'meta_value' => $requested_slug,
                'numberposts' => 1,
            ];
            $posts = get_posts($args);

            if (!empty($posts)) {
                $post = $posts[0];
            }
        }

        if ($post) {
            wp_redirect(get_permalink($post->ID), 301);
            exit;
        }
    }
});

// ===== Customize slug with stop words removal =====
add_filter('sanitize_title', function($slug, $raw_title, $context) {
    if ($context !== 'save') return $slug;

    $stop_words = ['a', 'an', 'the', 'and', 'or', 'but', 'in', 'on', 'at', 'to'];
    $pattern = '/(' . implode('|', $stop_words) . ')/i';

    $cleaned = preg_replace($pattern, '', $raw_title);
    $cleaned = sanitize_title_with_dashes($cleaned, null, 'save');

    return $cleaned ?: $slug; // fallback to original if empty
}, 10, 3);

// ===== Gutenberg/Block Editor: update slug programmatically =====
// In a WordPress plugin using wp.data (JavaScript):
/*
const { editPost } = wp.data.dispatch('core/editor');

function updateSlugFromTitle(title) {
    const slug = title
        .toLowerCase()
        .replace(/[^a-z0-9s-]/g, '')
        .trim()
        .replace(/s+/g, '-');

    editPost({ slug });
}

// Subscribe to title changes:
wp.data.subscribe(() => {
    const title = wp.data.select('core/editor').getEditedPostAttribute('title');
    if (title) updateSlugFromTitle(title);
});
*/

Headless CMS Slug Patterns (Contentful, Sanity, Strapi)

// ===== Contentful: slug field with validation =====
// In Contentful content model, add a "Short text" field with:
// - Field ID: slug
// - Appearance: Slug (auto-generates from title field)
// - Validation: Unique (prevents duplicates)
// - Regex validation: ^[a-z0-9]+(?:-[a-z0-9]+)*$

// Fetch by slug:
import { createClient } from 'contentful';

const client = createClient({
  space: process.env.CONTENTFUL_SPACE_ID!,
  accessToken: process.env.CONTENTFUL_ACCESS_TOKEN!,
});

async function getPostBySlug(slug: string) {
  const entries = await client.getEntries({
    content_type: 'blogPost',
    'fields.slug': slug,
    limit: 1,
  });

  return entries.items[0] ?? null;
}

// ===== Sanity: slug type with custom generation =====
// In schema definition (sanity/schemas/post.ts):
export default {
  name: 'post',
  type: 'document',
  fields: [
    { name: 'title', type: 'string' },
    {
      name: 'slug',
      type: 'slug',
      options: {
        source: 'title',            // auto-generate from title
        maxLength: 60,
        slugify: (input: string) =>
          input
            .toLowerCase()
            .normalize('NFD')
            .replace(/[̀-ͯ]/g, '')
            .replace(/[^a-z0-9s-]/g, '')
            .trim()
            .replace(/s+/g, '-')
            .replace(/-+/g, '-'),
        isUnique: async (slug: string, context: { document: { _id: string }; getClient: Function }) => {
          const { document, getClient } = context;
          const client = getClient({ apiVersion: '2024-01-01' });
          const id = document._id.replace(/^drafts./, '');
          const params = { draft: `drafts.${id}`, published: id, slug };
          const query = `!defined(*[!(_id in [$draft, $published]) && slug.current == $slug][0]._id)`;
          return await client.fetch(query, params);
        },
      },
    },
  ],
};

Try the Online Slug Generator

Convert any title or text to a clean, SEO-friendly URL slug instantly — with options for stop word removal, custom separators, max length, and Unicode support.

Open Slug Generator →

Key Takeaways

  • Use hyphens, not underscores: Google treats hyphens as word separators. my-blog-post ranks for both “my”, “blog”, and “post” separately.
  • Always lowercase: Uppercase slugs create duplicate content issues and confuse server routing on case-sensitive systems.
  • Remove accents via Unicode normalization: Use .normalize('NFD').replace(/[\u0300-\u036f]/g, '') before any other processing.
  • Keep slugs under 60 characters: Remove stop words (a, an, the, in, on, at) and truncate at word boundaries.
  • Enforce DB-level uniqueness: Always add a UNIQUE constraint on the slug column regardless of application-level checks.
  • Auto-increment suffix for conflicts: When a slug exists, append -2, -3 etc. — never use random strings or timestamps.
  • 301 redirect on slug change: Store old slugs and redirect permanently. Never leave broken URLs — they bleed PageRank and confuse crawlers.
  • Use limax for CJK content: The limax library handles Japanese, Chinese, Korean, Arabic, and 100+ other languages with accurate transliteration.
  • slugify npm for Western languages: Simple, fast, zero dependencies — perfect for English and European content with accent stripping.
  • Canonical URLs prevent duplicates: Always add rel="canonical" and be consistent about trailing slashes across your entire site.
𝕏 Twitterin LinkedIn
Cet article vous a-t-il aidé ?

Restez informé

Recevez des astuces dev et les nouveaux outils chaque semaine.

Pas de spam. Désabonnez-vous à tout moment.

Essayez ces outils associés

🔗URL Slug Generator%20URL Encoder/DecoderAaString Case ConverterAaLorem Ipsum Generator

Articles connexes

Convertisseur de Casse: Convertir camelCase, snake_case, kebab-case en Ligne — Guide Complet

Convertissez entre camelCase, PascalCase, snake_case, kebab-case. Guide avec change-case JavaScript, Python humps, regex et types TypeScript.

Échappement HTML: Encoder les Caractères Spéciaux en Ligne — Guide Complet

Échappez et déséchappez HTML, URL, JSON, SQL et chaînes shell. Guide XSS, entités HTML, encodage URL, JSON, injection SQL et regex.