Mert Özen Further With Every Line
SEO & GEO Technical SEO — #01

SEO-Friendly URL Design: Slugs, Lowercase, Trailing Slashes and Query Parameters

Mert Özen Nov 8, 2026 12 min 3 views
SEO-Friendly URL Design: Slugs, Lowercase, Trailing Slashes and Query Parameters

An SEO-friendly URL is single and permanent: lowercase, readable words separated by hyphens, a consistent trailing slash and a query free of parameters that do not change the content. We look at a C# function that turns Turkish titles into ASCII slugs, 301 normalization in ASP.NET Core, the History API instead of fragments in the Angular router, and mertozen.com slugs, with real outputs.

Version note: The code in this post was built and run with .NET 10 (ASP.NET Core 10) and Angular 22.2; Google's URL (Uniform Resource Locator) structure recommendations are as of November 2026.

Guess First

The Bookstore team has just shipped its new book pages. Marketing wrote /Books/Dune in a campaign email, a blogger linked to /books/dune/, and the site's own menu generates /books/dune. All three open the same page in a browser. How many pages does a search engine see: one or three?

Second question: one of the developers wrote a small function that turns a book title into a URL. For the Turkish title "Şeker Portakalı" it produces eker-portakal, and for "LINQ İPUÇLARI", a book on LINQ (Language Integrated Query), it produces l-nq-ipu-lar. The page loads and the tests pass. If these URLs go live, how expensive is it to fix them later?

This is the first post in the Technical SEO (Search Engine Optimization) series. The series covers, from a developer's point of view and with code, the infrastructure that lets a search engine crawl, render and index a site correctly. The concepts of crawling, indexing and ranking themselves live in the SEO Fundamentals series; here we turn those concepts into code. The first stop is the key to everything: the URL.

What Does Google Expect from a URL?

The URL structure page on Google Search Central is short but clear. The technical ground rule: Google supports URLs as defined by the IETF's (Internet Engineering Task Force) STD (Internet Standard) 66, i.e. RFC (Request for Comments) 3986, and reserved characters must be percent-encoded. On top of that come the readability recommendations:

  • Meaningful words instead of long IDs: /books/dune beats /p?id=83721.
  • Separate words with hyphens, not underscores; joining words together (greendress) is not recommended either.
  • Words in your audience's language can be used, transliterated if needed; non-ASCII (American Standard Code for Information Interchange) characters are percent-encoded inside href.
  • URLs are case-sensitive: Google treats /APPLE and /apple as different URLs.
  • Don't use fragments (#) to change page content; if content is loaded with JavaScript, the History API (Application Programming Interface) is recommended.
  • Query parameters are written as key=value joined by &; parameters that don't change the content are dropped.

The hyphen vs underscore question was clarified in the documentation on 23 October 2026. The reasoning is historical: programming languages use underscores to join terms (book_title is one identifier), while a hyphen marks a word boundary. So if you write dune_messiah, there is no guarantee that Google reads it as two words. You don't need to change existing underscore URLs just for this; a change always means redirects and temporary fluctuation. If you're building a new structure, start with hyphens.

Note: Google does not publish any weight or ratio for how words in the URL affect ranking. The real payoff of URL design lies elsewhere: one permanent address, a readable search result, a link whose meaning is obvious when shared, and a crawler that doesn't get lost in endless variations.

From a Turkish Title to an ASCII Slug

At Bookstore, book and blog page URLs are built from a slug generated from the title. First, the naive version that "works":

// naive version: culture-sensitive ToLower + drop everything non-ASCII
app.MapGet("/naive", (string title) =>
{
    string lower = title.ToLower(new System.Globalization.CultureInfo("tr-TR"));
    string ascii = System.Text.RegularExpressions.Regex.Replace(lower, "[^a-z0-9]+", "-");
    return ascii.Trim('-');
});
Şeker Portakalı -> eker-portakal
LINQ İPUÇLARI -> l-nq-ipu-lar
Ağaçlar Ölürken -> a-a-lar-l-rken

Two separate bugs stack up here. Under the tr-TR culture, uppercase I becomes the dotless ı (the famous Turkish I problem), and then the regex turns every non-ASCII letter into a hyphen. The result is unreadable, the word boundaries are wrong, and once it's live it's permanent. The right approach is to map letters to their equivalents instead of dropping them:

using System.Globalization;
using System.Text;

namespace Bookstore.Urls;

public static class Slugifier
{
    // characters that do not decompose under Unicode normalization
    private static readonly Dictionary<char, string> Special = new()
    {
        ['ı'] = "i", ['İ'] = "i", ['ß'] = "ss", ['æ'] = "ae", ['ø'] = "o", ['đ'] = "d", ['ł'] = "l",
    };

    public static string ToSlug(string title, int maxLength = 80)
    {
        string text = title
            .Replace("C#", "csharp", StringComparison.OrdinalIgnoreCase)
            .Replace(".NET", "net", StringComparison.OrdinalIgnoreCase);

        StringBuilder sb = new(text.Length);
        foreach (char c in text.Normalize(NormalizationForm.FormD))
        {
            if (CharUnicodeInfo.GetUnicodeCategory(c) == UnicodeCategory.NonSpacingMark)
                continue; // drop combining marks: c + cedilla -> c
            if (Special.TryGetValue(c, out string? mapped))
                sb.Append(mapped);
            else if (char.IsAsciiLetterOrDigit(c))
                sb.Append(char.ToLowerInvariant(c));
            else if (c is '\'' or '’')
                continue; // "C#'ta" -> "csharpta"
            else
                sb.Append('-');
        }

        string slug = string.Join('-', sb.ToString().Split('-', StringSplitOptions.RemoveEmptyEntries));
        if (slug.Length <= maxLength)
            return slug;

        int cut = slug.LastIndexOf('-', maxLength);
        return cut > 0 ? slug[..cut] : slug[..maxLength];
    }
}

The key is NormalizationForm.FormD. This form splits "ç" into "c" plus a combining cedilla, and "ö" into "o" plus a diaeresis; dropping the marks in the NonSpacingMark category leaves the base letter. But "ı" and "İ" (and a few letters such as "ß" and "ø") don't decompose, so a table maps them. Lowercasing uses ToLowerInvariant; the culture-dependent ToLower can give different results depending on the server's regional settings. The apostrophe is dropped entirely so that the Turkish suffix in "C#'ta" yields csharpta, not csharp-ta. Real outputs:

C#'ta Async/Await: Task, ValueTask ve Deadlock -> csharpta-async-await-task-valuetask-ve-deadlock
.NET 10'a Geçiş Rehberi -> net-10a-gecis-rehberi
Şeker Portakalı -> seker-portakali
İSTANBUL'da Öğle Arası -> istanbulda-ogle-arasi
Ağaçlar Ölürken: Çığ Gibi Büyüyen Sorunlar -> agaclar-olurken-cig-gibi-buyuyen-sorunlar

The length limit cuts at the last hyphen so it never splits a word. To be clear, this limit is not an SEO rule: Google gives no number for URL length. It's the limit of the CMS (Content Management System) field and of readability.

The alternative to transliteration is keeping Turkish characters in the URL. Google supports it; /kitaplar/şeker-portakalı is a valid URL and is percent-encoded inside href as %C5%9Feker-portakal%C4%B1. In practice the trouble isn't Google but everywhere else the URL travels: the percent-encoded form you get when you copy it, email clients, log analysis, and older systems producing two different byte sequences through different Unicode normalization forms. An ASCII slug closes this whole class of problems up front, and "seker-portakali" is perfectly readable for a Turkish reader.

Case and Trailing Slashes: One URL Rule

The answer to the first opening question: three. Google treats /Books/Dune and /books/dune as different URLs, and for paths other than the root, /books/dune/ and /books/dune are two separate URLs as well. If your server returns the same content with 200 for all three, Google has to group them as duplicates and pick one as canonical. Instead of leaving that choice to Google, set the rule yourself: generate one form and send the others to it with a 301.

using Bookstore.Urls;

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Services.Configure<RouteOptions>(o =>
{
    o.LowercaseUrls = true;        // generated links: /books/dune
    o.AppendTrailingSlash = false; // no "/books/dune/"
});

WebApplication app = builder.Build();

// one URL per page: uppercase or trailing slash -> 301 to the canonical form
app.Use(async (context, next) =>
{
    string path = context.Request.Path.Value ?? "/";
    string normalized = path.Length > 1 ? path.TrimEnd('/') : path;
    normalized = normalized.ToLowerInvariant();

    if (normalized != path)
    {
        context.Response.Redirect(normalized + context.Request.QueryString, permanent: true);
        return;
    }
    await next();
});

app.MapGet("/books/{slug}", (string slug) => $"Book page: {slug}")
   .WithName("BookDetail");

app.MapGet("/links", (LinkGenerator links) =>
    links.GetPathByName("BookDetail", new { slug = "Dune-Messiah", page = 2 }));

app.MapGet("/slug", (string title) => Slugifier.ToSlug(title));

app.Run();

There are two separate jobs here. RouteOptions only affects the links the application generates itself: LinkGenerator, Razor tag helpers and Url.Action all produce lowercase paths without a trailing slash. It does not affect incoming requests; ASP.NET Core routing already matches case-insensitively, so /Books/Dune returns 200 even if you do nothing. That's exactly what creates the duplicate. That's why the middleware is needed:

$ curl -s http://localhost:20101/links
/books/dune-messiah?page=2

$ curl -si http://localhost:20101/Books/Dune/
HTTP/1.1 301 Moved Permanently
Location: /books/dune

$ curl -si "http://localhost:20101/BOOKS/Dune?ref=Home"
HTTP/1.1 301 Moved Permanently
Location: /books/dune?ref=Home

Two details are worth noticing. LowercaseUrls also lowercases the route value (Dune-Messiah → dune-messiah) but leaves the query string alone; there's a separate LowercaseQueryStrings option for that, and before turning it on, think about whether query values are case-sensitive (a token or a base64 value would break). The middleware also carries the query string over unchanged. When you write this rule in your own application, make sure the route parameters really are case-insensitive; paths that carry a case-sensitive identifier (such as a short link code) must stay outside this rule.

There is no answer to which trailing slash form is "right"; Google accepts both. What's right is consistency: pick one, always generate your links, sitemap and canonicals in that form, and redirect the other. You can also set up the same rule as a rewrite in IIS (Internet Information Services) or Nginx in front of the app; that configuration belongs to the Web Servers and Hosting series. We'll open up redirects themselves (301 or 308, chains) in the seventh post of this series.

Query Parameters and the Infinite URL Space

Think of Bookstore's category page: /books?genre=scifi&sort=price&page=2. If filters can be combined (genre × author × price range × language × sort order × page), the number of URLs grows multiplicatively. Google's documentation calls this "additive filtering" and says plainly that Google needs a few lists from which it can reach every item, not every combination. Other URL generators fall into the same class:

  • Session IDs or tracking parameters (?sid=789, ?ref=newsletter): they don't change the content; use a cookie for the session, and drop tracking parameters from the canonical.
  • Sort parameters: they show the same list in a different order, with no new content.
  • Infinite calendars: a "next month" link produces new URLs forever; Google recommends nofollow on links to dynamically created future calendar pages.
  • Broken relative links: a parent-relative link such as href="books/dune" produces an endless path like /books/books/books/... on a server that doesn't return a real 404 for nonexistent paths. The fix is a root-relative link (/books/dune).

If you need multiple values, Google's recommendation is a comma: ?genre=scifi,fantasy. Also fix the parameter order; ?a=1&b=2 and ?b=2&a=1 are different URLs. The blog list on mertozen.com is a good example: category, series, tag, sort order and page come in as query parameters, but when the canonical is generated the parameters are always placed in the same order, default values (sort=publishedAt:desc, page=1) are dropped, and the on-site search term (q) never makes it into the canonical. So however many routes lead to the same list, a single address is declared. Managing which parameter combinations get crawled with robots.txt, and which get indexed with canonical and noindex, comes in the next posts of the series.

Fragments and the Angular Router

A fragment (everything after #) stays in the browser and is never sent to the server. So https://bookstore.example/#/books/dune and https://bookstore.example/#/books/foundation are the same URL to a crawler: the root page. Old AngularJS applications, and Angular projects still set up with withHashLocation() today, are in exactly this situation. Google's documentation is explicit: don't use fragments to change content, use the History API.

The Angular router already uses the History API by default (PathLocationStrategy); what you need to do is lay out route paths according to your URL design and generate links as real <a href> elements:

export const routes: Routes = [
  { path: '', component: Home },
  { path: 'books/:slug', component: BookDetail },
  { path: 'old-books/:slug', redirectTo: 'books/:slug' },
  { path: 'search', component: Search },
  { path: '**', component: NotFound },
];
<!-- correct: routerLink produces a real href -->
<a [routerLink]="['/books', book.slug]">{{ book.title }}</a>
<!-- not a link for a crawler: no href -->
<button (click)="openFoundation()">Foundation</button>

Run the application with SSR (Server-Side Rendering) and look at the HTML (HyperText Markup Language) the server sends, and the difference is obvious:

$ curl -s http://localhost:20110/ | grep -o '<a [^>]*>[^<]*</a>\|<button[^>]*>[^<]*</button>'
<a routerlink="/" href="/en/" jsaction="click:;">Home</a>
<a routerlink="/search" href="/search?q=dune" jsaction="click:;">Search</a>
<a href="/books/dune" jsaction="click:;">Dune</a>
<a href="/books/foundation" jsaction="click:;">Foundation</a>
<a href="/books/neuromancer" jsaction="click:;">Neuromancer</a>
<button jsaction="click:;">Foundation</button>

The book links arrive as href="/books/dune"; the button goes nowhere as far as a crawler is concerned. Google only follows <a> elements that have an href as links. The same rules apply to route paths: lowercase, hyphens, meaningful words. Because the Angular router matches paths case-sensitively by default, a request for /Books/dune falls through to the ** route; turning that into a 404 under SSR is the topic of the sixth post. For Angular routing basics, see "Angular Routing Guide: RouterModule, Route Parameters and Child Routes" in the Angular series.

mertozen.com's Slugs: A Truncated Slug Is Permanent Too

This site's own URLs make a good field example. Turkish page names are transliterated into ASCII: /videolar, /hakkimda, /araclar, /iletisim. Blog slugs are generated from the title, and Turkish suffixes stay attached to the word: csharpta-..., net-10a-.... I ran the Slugifier above on the Turkish title of the twentieth post in the ASP.NET Core Web API series:

$ curl -s -G --data-urlencode "title=ASP.NET Core Web API'de Pagination, Filtering ve Sorting: Büyük Listeleri Güvenle Döndürmek" http://localhost:20101/slug
aspnet-core-web-apide-pagination-filtering-ve-sorting-buyuk-listeleri-guvenle

The result is identical to the slug the post was published with: because of the 80-character limit, the last word "dondurmek" fell off. A small flaw for readers, but the real lesson is this: that address is now the post's permanent identity. Links from other sites, Google's index, social media shares and the sitemap all know this address. The moment you say "let me fix the slug", you owe a 301 rule for the old address. A truncated slug is always cheaper than a broken link.

By the same logic, changing a post's title later should not change its slug automatically. Generating the slug from the title only on the first save and then keeping it as a separate, locked field in the CMS prevents most errors of this kind up front.

Production Notes

Guarantee slug uniqueness in the database: two books with the same title (two different editions) produce the same slug. Add a unique index, and on a collision append the author or year (dune-1965); a counter (dune-2) should be the last resort, since it carries no meaning.

When a slug changes, don't delete the old one; keep it in a history table and return a 301. We build a database-backed example of this in the seventh post. Watch redirected and 404 pages in the "Pages" report of Search Console (Google's free dashboard for site owners); a spike in 404s after a URL change usually means a forgotten old link.

Put the normalization middleware at the start of the pipeline, before static files and routing. But test the ordering: if you have file names that really are uppercase, such as /Images/Cover.JPG, the lowercasing rule breaks them. Either publish static files in lowercase or apply the rule only to page paths. The logic of pipeline ordering is covered in "The ASP.NET Core Middleware Pipeline: Ordering, Use, Run, Map and Your Own Middleware" in the ASP.NET Core Web API series.

On the measurement side, the signal to watch is whether the same content shows up under more than one URL. Looking at how often the same slug is requested in different forms in your server logs (uppercase, trailing slash, tracking parameter) tells you whether the rule really works. Log analysis is the eleventh post of the series.

Common Pitfalls

Changing a slug after publishing without adding a redirect. This is the most expensive URL mistake: everything that knew the old address lands on a 404, and the signals the page accumulated don't move to the new address.

Culture-sensitive ToLower(). Code that works correctly on a developer machine with en-US turns LINQ into lınq on a server with Turkish regional settings. Always use ToLowerInvariant() when generating URLs, file names and identifiers.

Giving every filter combination a crawlable link. If the filter UI (User Interface) produces hundreds of combinations with <a href>, those combinations eat the crawler's time. Make the important combinations (such as a genre page) real URLs and manage the rest with canonicals, robots.txt or forms/JavaScript.

Inconsistency in the application's own links. If the menu still generates /Books/Dune while a redirect rule exists, every click costs an extra redirect. That's why LowercaseUrls sits next to the middleware.

Shipping an SPA (Single-Page Application) with fragment routes. #/books/dune doesn't exist for a crawler; switching to the History API later turns into a migration project for every link ever shared.

A Small Experiment

★☆☆ Run Slugifier.ToSlug against ten titles from your own site and compare the results with the existing slugs. Where they differ, go by which one is live, not which one is right; the live address is permanent.

★★☆ Add an exception list to the normalization middleware: short links in the form /s/{code} must stay case-sensitive and must not be lowercased. Check what both /S/AbC and /s/AbC return with curl -si.

★★★ Write a query parameter policy for the Bookstore category page: which parameters (genre, page) go into the canonical, which ones (sort, ref, sid) are dropped, and in what order are they placed? Write it as a single function that builds the canonical URL from the incoming query, and test it with five requests with reordered, missing or extra parameters.

The URLs are now single and permanent. But which of them the crawler visits, and which it never touches, is decided by a single file at the root of the site. In the next post we'll look at the rules of robots.txt as standardized by RFC 9309, how the most specific match is chosen and what you should never block, with a robots.txt generated per environment in ASP.NET Core.