ASP.NET Core MVCでSEOに強いURL(スラッグ)を実装する完全ガイド|ルーティング設計・自動生成・301リダイレクトまで

ASP.NET Core MVCで「?id=123」型のURLから、意味の分かるスラッグ(/article/this-is-my-article)へ移行するための実装を、最短手順から運用の落とし穴までまとめて解説します。ルーティング、コントローラー設計、スラッグ自動生成、EF Coreの一意制約、301リダイレクト、ビューの出力まで、実務でそのまま使えるコード中心です。

目次

ASP.NET Core MVCでSEOに強いURL(スラッグ)を実装する

目的はクエリストリング依存のURL(例:/articles?article=this_is_my_article)をやめ、階層型で意味のあるパス(例:/article/this-is-my-article)に統一することです。これにより、URL自体がコンテンツの意味を伝え、クリック率や共有時の可読性が向上し、内部リンクの設計もシンプルになります。

完成形(最短手順)

まずは「とりあえず動く」構成です。後述の高度な最適化や運用ノウハウは、この基本形の上に積み上げます。

Program.cs(.NET 6+ 最小ホスティング)


// Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllersWithViews();

var app = builder.Build();
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthorization();

// 公開用: /article/{slug} を ArticlesController.Details に結びつける
app.MapControllerRoute(
    name: "article",
    // スラッグは小文字英数とハイフンのみ(日本語を許可する場合は制約を外す)
    pattern: "article/{slug:regex(^[a-z0-9]+(?:-[a-z0-9]+)*$)}",
    defaults: new { controller = "Articles", action = "Details" });

// 既存のデフォルトルート(管理画面や内部用に残す)
app.MapControllerRoute(
    name: "default",
    pattern: "{controller=Articles}/{action=Index}/{id?}");

app.Run();

モデル(最小)


// Models/Article.cs
public class Article
{
    public int Id { get; set; }

    // 記事タイトル
    public string Title { get; set; } = string.Empty;

    // URL 用スラッグ(ユニーク)
    public string Slug { get; set; } = string.Empty;

    // 本文
    public string Content { get; set; } = string.Empty;
}

コントローラー(最小・インメモリ)


// Controllers/ArticlesController.cs
using Microsoft.AspNetCore.Mvc;

public class ArticlesController : Controller
{
    private static readonly List<Article> _articles = new()
    {
        new Article { Id = 1, Title = "Hello World",
                      Slug = "hello-world", Content = "ようこそブログへ" },
        new Article { Id = 2, Title = "ASP.NET Core で SEO 対策",
                      Slug = "seo-friendly-urls-in-aspnet-core",
                      Content = "アンダースコアでなくハイフンを使いましょう" }
    };

    public IActionResult Index() => View(_articles);

    public IActionResult Details(string slug)
    {
        var article = _articles.FirstOrDefault(a => a.Slug == slug);
        return article is null ? NotFound() : View(article);
    }
}

ビューでのリンク生成

Index一覧から詳細へのリンクは アンカータグ・ヘルパーで簡潔に書けます。ルート名を指定すると、/article/{slug}が自動生成されます。


// Views/Articles/Index.cshtml
@model IEnumerable<Article>

<ul>
@foreach (var article in Model)
{
    <li>
        <a asp-route="article" asp-route-slug="@article.Slug">
            @article.Title
        </a>
    </li>
}
</ul>

// Views/Articles/Details.cshtml
@model Article

<article>
  <h1>@Model.Title</h1>
  <div>@Model.Content</div>

  @* カノニカルURL(後述のルート名 "article" を使用) *@
  <link rel="canonical"
        href="@Url.RouteUrl("article", new { slug = Model.Slug }, Context.Request.Scheme)" />
</article>

URL設計の考え方:コントローラー名・アクション名を出すべきか?

公開URLは情報の意味・構造を最短で伝えることが目的です。
/Articles/Details/123 のようにコントローラーやアクションを露出すると、実装都合がユーザーに漏れ、URLが長く覚えづらくなります。そこで、/article/{slug} のように名詞ベースのフラットな設計を推奨します。一方、管理画面や内部機能は/Admin/Articles/Edit/123のように従来のデフォルトルートを併用すれば十分です。

用途URL例メリットデメリット
公開(推奨)/article/this-is-my-article短い・覚えやすい・共有されやすい・キーワード含有実装の所在が直感的に分からない
内部/管理/Admin/Articles/Edit/123開発者に分かりやすい・機能パスが表現的公開URLとしては冗長・変更に弱い

スラッグの作り方:自動生成のアルゴリズム

新規投稿時に人為ミスを避けるため自動生成が基本です。一般的な手順は次のとおり。

  1. 小文字化
  2. アクセント記号などのダイアクリティカルマークを除去
  3. 全角→半角の正規化(NFKC)
  4. 英数字と空白以外を削除(必要に応じて日本語→ローマ字変換)
  5. 空白をハイフンに置換
  6. ハイフン重複を1つに圧縮し、前後のハイフンを除去

以下は実務投入できるサンプル実装です。日本語タイトルのローマ字化に簡易対応し、ユニーク化までカバーします。


// Utils/SlugHelper.cs
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
using Microsoft.EntityFrameworkCore;

public static class SlugHelper
{
    // ASCIIとハイフンのみ許容する正規表現
    private static readonly Regex NonSlugChars = new("[^a-z0-9\\- ]+", RegexOptions.Compiled);
    private static readonly Regex MultiHyphens = new("\\-+", RegexOptions.Compiled);
    private static readonly Regex MultiSpaces  = new("\\s+", RegexOptions.Compiled);

    /// <summary>
    /// タイトルからスラッグを生成(日本語は簡易ローマ字化)
    /// </summary>
    public static string GenerateSlug(string title)
    {
        if (string.IsNullOrWhiteSpace(title)) return string.Empty;

        // 1) 正規化(NFKCで全角→半角も)
        var normalized = title.Normalize(NormalizationForm.FormKD);
        normalized = normalized.Normalize(NormalizationForm.FormKC);

        // 2) 日本語かな/カナ を簡易ローマ字に変換(必要に応じて拡張)
        var romaji = ToRomaji(normalized);

        // 3) アクセント除去(ラテン文字想定)
        var sb = new StringBuilder();
        foreach (var c in romaji.Normalize(NormalizationForm.FormD))
        {
            var uc = CharUnicodeInfo.GetUnicodeCategory(c);
            if (uc != UnicodeCategory.NonSpacingMark)
                sb.Append(c);
        }
        var ascii = sb.ToString().Normalize(NormalizationForm.FormC);

        // 4) 小文字化
        ascii = ascii.ToLowerInvariant();

        // 5) 英数字・空白・ハイフン以外を除去
        ascii = NonSlugChars.Replace(ascii, " ");

        // 6) 連続空白→単一スペース
        ascii = MultiSpaces.Replace(ascii, " ").Trim();

        // 7) 空白→ハイフン
        ascii = ascii.Replace(' ', '-');

        // 8) 連続ハイフン→1
        ascii = MultiHyphens.Replace(ascii, "-").Trim('-');

        // 空になった場合のフォールバック
        return string.IsNullOrEmpty(ascii) ? "post" : ascii;
    }

    /// <summary>
    /// かな/カナ→ローマ字(簡易)。実運用では辞書を拡張推奨。
    /// </summary>
    private static string ToRomaji(string input)
    {
        if (string.IsNullOrEmpty(input)) return input;

        // 拗音・促音などの先に2文字パターンを処理
        var map2 = new Dictionary<string, string>(StringComparer.Ordinal)
        {
            ["きゃ"]="kya", ["きゅ"]="kyu", ["きょ"]="kyo",
            ["しゃ"]="sha", ["しゅ"]="shu", ["しょ"]="sho",
            ["ちゃ"]="cha", ["ちゅ"]="chu", ["ちょ"]="cho",
            ["にゃ"]="nya", ["にゅ"]="nyu", ["にょ"]="nyo",
            ["ひゃ"]="hya", ["ひゅ"]="hyu", ["ひょ"]="hyo",
            ["みゃ"]="mya", ["みゅ"]="myu", ["みょ"]="myo",
            ["りゃ"]="rya", ["りゅ"]="ryu", ["りょ"]="ryo",
            ["ぎゃ"]="gya", ["ぎゅ"]="gyu", ["ぎょ"]="gyo",
            ["じゃ"]="ja",  ["じゅ"]="ju",  ["じょ"]="jo",
            ["びゃ"]="bya", ["びゅ"]="byu", ["びょ"]="byo",
            ["ぴゃ"]="pya", ["ぴゅ"]="pyu", ["ぴょ"]="pyo",
            // カタカナも
            ["キャ"]="kya", ["キュ"]="kyu", ["キョ"]="kyo",
            ["シャ"]="sha", ["シュ"]="shu", ["ショ"]="sho",
            ["チャ"]="cha", ["チュ"]="chu", ["チョ"]="cho",
            ["ニャ"]="nya", ["ニュ"]="nyu", ["ニョ"]="nyo",
            ["ヒャ"]="hya", ["ヒュ"]="hyu", ["ヒョ"]="hyo",
            ["ミャ"]="mya", ["ミュ"]="myu", ["ミョ"]="myo",
            ["リャ"]="rya", ["リュ"]="ryu", ["リョ"]="ryo",
            ["ギャ"]="gya", ["ギュ"]="gyu", ["ギョ"]="gyo",
            ["ジャ"]="ja",  ["ジュ"]="ju",  ["ジョ"]="jo",
            ["ビャ"]="bya", ["ビュ"]="byu", ["ビョ"]="byo",
            ["ピャ"]="pya", ["ピュ"]="pyu", ["ピョ"]="pyo",
        };

        var map1 = new Dictionary<char, string>()
        {
            ['あ']="a",  ['い']="i",  ['う']="u",  ['え']="e",  ['お']="o",
            ['か']="ka", ['き']="ki", ['く']="ku", ['け']="ke", ['こ']="ko",
            ['さ']="sa", ['し']="shi",['す']="su", ['せ']="se", ['そ']="so",
            ['た']="ta", ['ち']="chi",['つ']="tsu",['て']="te", ['と']="to",
            ['な']="na", ['に']="ni", ['ぬ']="nu", ['ね']="ne", ['の']="no",
            ['は']="ha", ['ひ']="hi", ['ふ']="fu", ['へ']="he", ['ほ']="ho",
            ['ま']="ma", ['み']="mi", ['む']="mu", ['め']="me", ['も']="mo",
            ['や']="ya", ['ゆ']="yu", ['よ']="yo",
            ['ら']="ra", ['り']="ri", ['る']="ru", ['れ']="re", ['ろ']="ro",
            ['わ']="wa", ['を']="o",  ['ん']="n",
            ['が']="ga", ['ぎ']="gi", ['ぐ']="gu", ['げ']="ge", ['ご']="go",
            ['ざ']="za", ['じ']="ji", ['ず']="zu", ['ぜ']="ze", ['ぞ']="zo",
            ['だ']="da", ['ぢ']="ji", ['づ']="zu", ['で']="de", ['ど']="do",
            ['ば']="ba", ['び']="bi", ['ぶ']="bu", ['べ']="be", ['ぼ']="bo",
            ['ぱ']="pa", ['ぴ']="pi", ['ぷ']="pu", ['ぺ']="pe", ['ぽ']="po",
            ['ぁ']="a",  ['ぃ']="i",  ['ぅ']="u",  ['ぇ']="e",  ['ぉ']="o",
            ['ゃ']="ya", ['ゅ']="yu", ['ょ']="yo",
            ['ゎ']="wa", ['っ']="",   ['ー']="-",
            // カタカナ
            ['ア']="a",  ['イ']="i",  ['ウ']="u",  ['エ']="e",  ['オ']="o",
            ['カ']="ka", ['キ']="ki", ['ク']="ku", ['ケ']="ke", ['コ']="ko",
            ['サ']="sa", ['シ']="shi",['ス']="su", ['セ']="se", ['ソ']="so",
            ['タ']="ta", ['チ']="chi",['ツ']="tsu",['テ']="te", ['ト']="to",
            ['ナ']="na", ['ニ']="ni", ['ヌ']="nu", ['ネ']="ne", ['ノ']="no",
            ['ハ']="ha", ['ヒ']="hi", ['フ']="fu", ['ヘ']="he", ['ホ']="ho",
            ['マ']="ma", ['ミ']="mi", ['ム']="mu", ['メ']="me", ['モ']="mo",
            ['ヤ']="ya", ['ユ']="yu", ['ヨ']="yo",
            ['ラ']="ra", ['リ']="ri", ['ル']="ru", ['レ']="re", ['ロ']="ro",
            ['ワ']="wa", ['ヲ']="o",  ['ン']="n",
            ['ガ']="ga", ['ギ']="gi", ['グ']="gu", ['ゲ']="ge", ['ゴ']="go",
            ['ザ']="za", ['ジ']="ji", ['ズ']="zu", ['ゼ']="ze", ['ゾ']="zo",
            ['ダ']="da", ['ヂ']="ji", ['ヅ']="zu", ['デ']="de", ['ド']="do",
            ['バ']="ba", ['ビ']="bi", ['ブ']="bu", ['ベ']="be", ['ボ']="bo",
            ['パ']="pa", ['ピ']="pi", ['プ']="pu", ['ペ']="pe", ['ポ']="po",
            ['ァ']="a",  ['ィ']="i",  ['ゥ']="u",  ['ェ']="e",  ['ォ']="o",
            ['ャ']="ya", ['ュ']="yu", ['ョ']="yo",
            ['ヮ']="wa", ['ッ']=""
        };

        // 促音「っ/ッ」対応(次の子音を重ねる)
        string DoubleConsonant(string next)
            => string.IsNullOrEmpty(next) ? string.Empty :
               (next[0] is 'a' or 'i' or 'u' or 'e' or 'o' or '-' ? next : $"{next[0]}{next}");

        var sb = new StringBuilder();
        for (int i = 0; i < input.Length; i++)
        {
            // 2文字マップ
            if (i + 1 < input.Length)
            {
                var two = input.Substring(i, 2);
                if (map2.TryGetValue(two, out var roma2))
                {
                    sb.Append(roma2);
                    i++;
                    continue;
                }
            }

            var ch = input[i];

            // 促音(次の子音を重ねる)
            if (ch is 'っ' or 'ッ')
            {
                if (i + 1 < input.Length)
                {
                    // 次の1文字をローマ字化(2文字拗音の先読みは簡易対応)
                    var lookahead = i + 2 < input.Length
                        ? input.Substring(i + 1, 2)
                        : input.Substring(i + 1, 1);
                    var roma = map2.ContainsKey(lookahead)
                        ? map2[lookahead]
                        : map1.TryGetValue(input[i + 1], out var r1) ? r1 : string.Empty;
                    sb.Append(DoubleConsonant(roma));
                }
                continue;
            }

            // 長音記号は直前母音の繰り返し相当。簡易実装ではハイフンへ。
            if (ch == 'ー')
            {
                sb.Append("-");
                continue;
            }

            // 一文字マップ
            if (map1.TryGetValue(ch, out var roma1))
            {
                sb.Append(roma1);
            }
            else
            {
                // それ以外はそのまま(英数字や記号は後段でフィルタ)
                sb.Append(ch);
            }
        }
        return sb.ToString();
    }

    /// <summary>
    /// DBの既存スラッグと衝突しないよう "foo", "foo-2", "foo-3"... に調整
    /// </summary>
    public static async Task<string> EnsureUniqueSlugAsync(
        DbContext db, string baseSlug, int? excludeId = null, CancellationToken ct = default)
    {
        if (string.IsNullOrEmpty(baseSlug)) baseSlug = "post";
        var slug = baseSlug;
        var n = 2;

        // Articlesセット名は実アプリのDbContextに合わせる
        bool exists = await db.Set<Article>()
            .AnyAsync(a => a.Slug == slug && (excludeId == null || a.Id != excludeId), ct);
        while (exists)
        {
            slug = $"{baseSlug}-{n++}";
            exists = await db.Set<Article>()
                .AnyAsync(a => a.Slug == slug && (excludeId == null || a.Id != excludeId), ct);
        }
        return slug;
    }
}

EF Coreで一意制約(Unique Index)を付ける

アプリケーション側の重複チェックだけでは競合に弱いので、DBレベルでもユニーク制約を入れます。


// Data/AppDbContext.cs
using Microsoft.EntityFrameworkCore;

public class AppDbContext : DbContext
{
    public DbSet<Article> Articles => Set<Article>();

    public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) {}

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        var e = modelBuilder.Entity<Article>();
        e.Property(x => x.Title).HasMaxLength(300).IsRequired();
        e.Property(x => x.Slug).HasMaxLength(200).IsRequired();
        e.HasIndex(x => x.Slug).IsUnique(); // 一意制約
        base.OnModelCreating(modelBuilder);
    }
}

マイグレーション実行後、DBはSlug列に対してユニークインデックスを保持します。これで高負荷時の重複作成も防げます。

作成・編集時にスラッグを自動生成/更新


// Controllers/ArticlesController.cs (EF 版・抜粋)
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;

public class ArticlesController : Controller
{
    private readonly AppDbContext _db;
    public ArticlesController(AppDbContext db) => _db = db;

    [HttpPost]
    [ValidateAntiForgeryToken]
    public async Task<IActionResult> Create(Article model, CancellationToken ct)
    {
        if (!ModelState.IsValid) return View(model);

        var baseSlug = SlugHelper.GenerateSlug(model.Title);
        model.Slug = await SlugHelper.EnsureUniqueSlugAsync(_db, baseSlug, null, ct);

        _db.Add(model);
        await _db.SaveChangesAsync(ct);

        // 作成直後にカノニカルURLへ
        return RedirectToRoute("article", new { slug = model.Slug });
    }

    [HttpPost]
    [ValidateAntiForgeryToken]
    public async Task<IActionResult> Edit(int id, Article input, CancellationToken ct)
    {
        var article = await _db.Articles.FindAsync(new object?[] { id }, ct);
        if (article is null) return NotFound();

        article.Title = input.Title;
        article.Content = input.Content;

        // タイトル変更に応じてスラッグも更新(運用方針により固定でも可)
        var baseSlug = SlugHelper.GenerateSlug(article.Title);
        article.Slug = await SlugHelper.EnsureUniqueSlugAsync(_db, baseSlug, article.Id, ct);

        await _db.SaveChangesAsync(ct);
        return RedirectToRoute("article", new { slug = article.Slug });
    }
}

カノニカル化と301リダイレクト(重複URL対策)

URLに旧スラッグや大文字を含むアクセスが来た場合、301(恒久)リダイレクトで正規URLに寄せて「ページの評価」を一箇所に集約します。最小実装では「IDで探してからスラッグを確認」または「スラッグで見つけたあと一致確認」のどちらかです。


// Controllers/ArticlesController.cs (一致しない場合は301で正規化)
public async Task<IActionResult> Details(string slug, CancellationToken ct)
{
    var article = await _db.Articles
        .FirstOrDefaultAsync(a => a.Slug == slug.ToLowerInvariant(), ct);

    if (article is null)
    {
        // 旧スラッグの別名テーブルを使う場合(後述)
        var redirect = await _db.ArticleSlugs
            .Include(x => x.Article)
            .FirstOrDefaultAsync(x => x.Slug == slug.ToLowerInvariant(), ct);

        if (redirect is null) return NotFound();

        return RedirectToRoutePermanent("article", new { slug = redirect.Article.Slug });
    }

    // クエリに大文字や余計なハイフンが混じるなど、非正規なら301で矯正
    if (!string.Equals(article.Slug, slug, StringComparison.Ordinal))
    {
        return RedirectToRoutePermanent("article", new { slug = article.Slug });
    }

    return View(article);
}

ビュー側では<link rel="canonical">も必ず出力しておきます(前掲のDetails.cshtml参照)。

別名(エイリアス)対応:スラッグを変更してもリンクを切らさない

タイトル変更に伴いスラッグを更新すると、旧URLの外部リンクが切れるリスクがあります。これを避けるため、スラッグを履歴として保存する別名テーブルを用意しておくと安全です。


// Models/ArticleSlug.cs
public class ArticleSlug
{
    public int Id { get; set; }
    public int ArticleId { get; set; }
    public string Slug { get; set; } = string.Empty; // 旧/別名スラッグ
    public DateTime CreatedAt { get; set; } = DateTime.UtcNow;

    public Article Article { get; set; } = default!;
}

// Data/AppDbContext.cs (OnModelCreatingに追加)
modelBuilder.Entity&lt;ArticleSlug&gt;()
    .HasIndex(s =&gt; s.Slug).IsUnique();

modelBuilder.Entity&lt;ArticleSlug&gt;()
    .HasOne(s =&gt; s.Article)
    .WithMany() // 必要なら Article.Slugs ナビゲーションを持たせる
    .HasForeignKey(s =&gt; s.ArticleId)
    .OnDelete(DeleteBehavior.Cascade);

編集時にスラッグが変わったら旧スラッグを履歴へ保存します。


// Controllers/ArticlesController.cs (編集時の履歴保存)
if (!string.Equals(article.Slug, baseSlug, StringComparison.Ordinal))
{
    var oldSlug = article.Slug;
    article.Slug = await SlugHelper.EnsureUniqueSlugAsync(_db, baseSlug, article.Id, ct);

    // 旧スラッグを別名として保存
    _db.Add(new ArticleSlug { ArticleId = article.Id, Slug = oldSlug });
}

ルーティングの掘り下げ:設計パターンと落とし穴

ルート順序と優先度

MapControllerRoute宣言順に照合されます。より具体的なパターンを先に書き、最後にデフォルトルートを置くのが基本です。つまり、"article/{slug}"を先、"{controller}/{action}/{id?}"を後にします。

正規表現制約の使いどころ

スラッグを「英数+ハイフン」に限定するなら、インライン制約を使うとマッチングが締まり、意図しないコントローラーへの流入を防げます。


pattern: "article/{slug:regex(^[a-z0-9]+(?:-[a-z0-9]+)*$)}"

日本語スラッグ(URLエンコードされたパス)を許容する場合は、制約を外してpattern: "article/{slug}"にします。ただし、共有時の視認性システム間連携の堅牢性はローマ字スラッグのほうが高い傾向があります。

属性ルーティングを併用する場合


// Controllers/ArticlesController.cs(属性ルート)
[Route("article")]
public class ArticlesController : Controller
{
    [HttpGet("{slug}")]
    public IActionResult Details(string slug) { ... }
}

属性ルーティング単独でも構いませんが、全体の一貫性見通しの観点から、本記事では慣例(Conventional)ルーティングで公開URLを1箇所に集約する方針を採用しています。

ID+スラッグの折衷案

パフォーマンスや衝突回避を重視する場合、/article/1234-this-is-my-articleのようにIDとスラッグを併記する設計も有力です。DB検索はIDで行い、スラッグ不一致時のみ301で正規化すれば、1回の主キー検索で済みます。


// Program.cs
app.MapControllerRoute(
    name: "article-id-slug",
    pattern: "article/{id:int}-{slug}",
    defaults: new { controller = "Articles", action = "DetailsById" });

// Controller
public async Task&lt;IActionResult&gt; DetailsById(int id, string slug)
{
    var article = await _db.Articles.FindAsync(id);
    if (article is null) return NotFound();

    if (!string.Equals(article.Slug, slug, StringComparison.Ordinal))
        return RedirectToRoutePermanent("article-id-slug", new { id, slug = article.Slug });

    return View("Details", article);
}

ビュー最適化:アンカータグ・ヘルパーとメタタグ

用途サンプル解説
ルート名でリンク<a asp-route="article" asp-route-slug="@article.Slug">コントローラー・アクション変更の影響を受けにくい
アクション指定でリンク<a asp-controller="Articles" asp-action="Details" asp-route-slug="...">ルート名がない場合の定番
正規URLの明示<link rel="canonical" href="..." />重複URLの評価分散を防ぐ

運用のベストプラクティス(SEO/UX視点)

  • ハイフン区切りを徹底(単語の境界が機械にも人にも分かりやすい)。
  • スラッグは極力短く、重要キーワードのみ(20~60文字目安)。
  • 小文字に統一、アンダースコアや特殊記号は使わない。
  • スラッグ生成は自動化し、編集画面では手動修正可にする。
  • タイトル変更に伴う301リダイレクトカノニカルの二重対策。
  • DBにはユニークインデックスを設定。アプリ側でも二重チェック。
  • 公開URLの仕様は壊さない(破壊的変更は必ずリダイレクトで吸収)。

チェックリスト(導入〜リリース)

項目チェック内容OK基準
ルートarticle/{slug} が最優先でマップされているデフォルトルートより上に宣言
制約英数+ハイフンのみ or 日本語許容の方針が決まっている正規表現 or 制約なしを統一
スラッグ生成タイトルからの自動生成&衝突回避実装があるDBユニーク制約と併用
リダイレクト非正規URLから正規URLへ301テストで確認済み
別名履歴旧スラッグ→現行スラッグのマッピングが保存される外部リンクが切れない
ビューアンカータグ・ヘルパーのルート名指定を使用実装変更に強い
セキュリティスラッグの入力長・文字種をサーバー側で検証不正入力が404/400に落ちる

よくあるエラーと対処

  • 期待したルートに当たらない:宣言順を見直し。より具体的なパターンを先に。
  • 404が増えた:制約が厳しすぎる可能性。日本語スラッグを許可するか、生成ルールを緩和。
  • 重複スラッグで例外:アプリ側の重複チェックとDBユニーク制約の両方を実装。
  • URLが長すぎる:生成時にストップワード削除・語尾を詰める・最大長でカット。
  • 旧URLから評価が引き継がれない:302ではなく301へ切り替え、カノニカルも出力。

スラッグ生成ルールの作り込み(実務Tips)

観点推奨ルール理由
文字小文字英数+ハイフンタイポを減らし、環境依存を避ける
長さ最大200文字(DBも合わせる)DB・ブラウザ・SNSでの扱いやすさ
記号基本禁止(&, ?, # など)解析や共有時のトラブル回避
重複-2, -3… を自動付与人手での微修正を不要化
日本語簡易ローマ字化 or 許容(制約なし)運用の方針に合わせて選択

セキュリティ・パフォーマンスの注意点

  • 入力検証[RegularExpression]やルート制約で異常値を遮断。
  • エンコード:スラッグはビューでHtmlEncodeされるが、出力時の生HTML埋め込みに注意。
  • DB競合:ユニーク制約によりDbUpdateExceptionが発生しうる。再生成リトライで吸収。
  • 検索効率Slug列にユニークインデックスがあればO(log n)でヒット。ID+スラッグ方式はさらに高速。
  • キャッシュ:アクセスの多い記事はメモリキャッシュを検討(変更時はキャッシュ無効化)。

Razor Pages/Minimal APIへの応用(参考)

MVC以外でも設計の本質は同じです。Razor Pagesなら/Pages/Article/{slug}.cshtmlのようにページルートを定義、Minimal APIならMapGet("article/{slug}", ...)で実装し、カノニカル化と301を同様に行います。

実装全体のサンプル(抜粋まとめ)


// Program.cs(まとめ)
app.MapControllerRoute(
    name: "article",
    pattern: "article/{slug:regex(^[a-z0-9]+(?:-[a-z0-9]+)*$)}",
    defaults: new { controller = "Articles", action = "Details" });

app.MapControllerRoute(
    name: "default",
    pattern: "{controller=Articles}/{action=Index}/{id?}");

// モデル
public class Article
{
    public int Id { get; set; }
    public string Title { get; set; } = string.Empty;
    public string Slug { get; set; } = string.Empty;
    public string Content { get; set; } = string.Empty;
}

// DbContext(Slugにユニークインデックス)
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    var e = modelBuilder.Entity&lt;Article&gt;();
    e.Property(x =&gt; x.Title).HasMaxLength(300).IsRequired();
    e.Property(x =&gt; x.Slug).HasMaxLength(200).IsRequired();
    e.HasIndex(x =&gt; x.Slug).IsUnique();
}

// スラッグ生成とユニーク化
var baseSlug = SlugHelper.GenerateSlug(model.Title);
model.Slug = await SlugHelper.EnsureUniqueSlugAsync(_db, baseSlug);

// 詳細アクションでの正規化
if (!string.Equals(article.Slug, slug, StringComparison.Ordinal))
    return RedirectToRoutePermanent("article", new { slug = article.Slug });

// ビューでのリンク生成(ルート名使用)
&lt;a asp-route="article" asp-route-slug="@article.Slug"&gt;@article.Title&lt;/a&gt;

「デフォルトルートのまま」でもできるか?

/Posts/GetPostsByBlog/WinForms のように、ID代わりの文字列をアクションに渡すだけでも要件自体は満たせます。ただし、SEO・UXの観点では冗長で、URLの寿命も実装変更に左右されがちです。/article/{slug}のような短く揺らぎにくい設計が、公開URLとしてはより好まれます。

まとめ

  • やることは2点article/{slug}ルートを追加し、コントローラーでslugを受け取る/タイトルからスラッグを自動生成し保存
  • DB側にユニーク制約を置き、衝突時は-2, -3で自動回避。
  • 旧URLや非正規URLは301でカノニカルURLへ。ビューでもrel="canonical"を出す。
  • 公開URLは名詞ベースで短く、管理系はデフォルトルートを併用する二層構成が現実的。

付録:フルコード(最小動作セット)

以下をそのまま配置すれば、/article/{slug}での表示と一覧からの遷移が確認できます。


// Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllersWithViews();
builder.Services.AddDbContext&lt;AppDbContext&gt;(opts =&gt; opts.UseInMemoryDatabase("articles"));

var app = builder.Build();
app.UseStaticFiles();
app.UseRouting();

app.MapControllerRoute(
    name: "article",
    pattern: "article/{slug:regex(^[a-z0-9]+(?:-[a-z0-9]+)*$)}",
    defaults: new { controller = "Articles", action = "Details" });

app.MapControllerRoute(
    name: "default",
    pattern: "{controller=Articles}/{action=Index}/{id?}");

Seed(app.Services);

app.Run();

static void Seed(IServiceProvider sp)
{
    using var scope = sp.CreateScope();
    var db = scope.ServiceProvider.GetRequiredService&lt;AppDbContext&gt;();
    if (!db.Articles.Any())
    {
        db.Articles.AddRange(
            new Article { Title = "Hello World", Slug = SlugHelper.GenerateSlug("Hello World"), Content = "ようこそブログへ" },
            new Article { Title = "ASP.NET Core で SEO 対策", Slug = SlugHelper.GenerateSlug("ASP.NET Core で SEO 対策"), Content = "ハイフン区切りが基本" }
        );
        db.SaveChanges();
    }
}

// Models/Article.cs
public class Article
{
    public int Id { get; set; }
    public string Title { get; set; } = string.Empty;
    public string Slug { get; set; } = string.Empty;
    public string Content { get; set; } = string.Empty;
}

// Data/AppDbContext.cs
using Microsoft.EntityFrameworkCore;

public class AppDbContext : DbContext
{
    public AppDbContext(DbContextOptions&lt;AppDbContext&gt; options) : base(options) {}
    public DbSet&lt;Article&gt; Articles =&gt; Set&lt;Article&gt;();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        var e = modelBuilder.Entity&lt;Article&gt;();
        e.Property(x =&gt; x.Title).HasMaxLength(300).IsRequired();
        e.Property(x =&gt; x.Slug).HasMaxLength(200).IsRequired();
        e.HasIndex(x =&gt; x.Slug).IsUnique();
    }
}

// Controllers/ArticlesController.cs
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;

public class ArticlesController : Controller
{
    private readonly AppDbContext _db;
    public ArticlesController(AppDbContext db) =&gt; _db = db;

    public async Task&lt;IActionResult&gt; Index()
        =&gt; View(await _db.Articles.AsNoTracking().ToListAsync());

    public async Task&lt;IActionResult&gt; Details(string slug)
    {
        var article = await _db.Articles.AsNoTracking()
            .FirstOrDefaultAsync(a =&gt; a.Slug == slug.ToLowerInvariant());
        if (article is null) return NotFound();

        if (!string.Equals(article.Slug, slug, StringComparison.Ordinal))
            return RedirectToRoutePermanent("article", new { slug = article.Slug });

        return View(article);
    }
}

// Views/Articles/Index.cshtml
@model IEnumerable&lt;Article&gt;

&lt;h2&gt;記事一覧&lt;/h2&gt;
&lt;ul&gt;
@foreach (var article in Model)
{
    &lt;li&gt;
        &lt;a asp-route="article" asp-route-slug="@article.Slug"&gt;@article.Title&lt;/a&gt;
    &lt;/li&gt;
}
&lt;/ul&gt;

// Views/Articles/Details.cshtml
@model Article

&lt;article&gt;
  &lt;h1&gt;@Model.Title&lt;/h1&gt;
  &lt;p&gt;@Model.Content&lt;/p&gt;
  &lt;link rel="canonical"
        href="@Url.RouteUrl("article", new { slug = Model.Slug }, Context.Request.Scheme)" /&gt;
&lt;/article&gt;

// Utils/SlugHelper.cs(前掲の実装を流用)

このセットで、一覧(/Articles/Index)からSEOフレンドリーな/article/{slug}に遷移し、正規URLの維持・自動生成・ユニーク制約までを再現できます。運用ではここに別名履歴管理UI構造化データなどを段階的に積み上げましょう。


FAQ(設計時に迷いやすいポイント)

Q:スラッグに日付(/2025/10/31/slug)を入れるべき?
A:運用でのURL寿命が短くなる(移動や再分類で壊れやすい)ため、原則は不要。カテゴリ分けは内部データで管理し、URLはコンテンツID(スラッグ)に集中させるのが堅実です。

Q:日本語そのままのスラッグはNG?
A:技術的には可能ですが、エンコード表示・共有時の視認性・一部環境との互換性で不利なことがあります。ローマ字スラッグ推奨、どうしても日本語を使う場合は正規表現制約を外して運用ルールを明文化してください。

Q:サイト全体を/article/配下にするべき?
A:投稿記事群だけを/article/にまとめ、固定ページは/aboutなど別プレフィックスでも問題ありません。情報設計を優先しましょう。


ここまで実装すれば、意味のあるパス+日本語を含まない安定URLが実現でき、クローラビリティとユーザー体験を同時に高められます。既存のデフォルトルーティングは内部用に残しつつ、公開URLはスラッグ一本で潔く設計するのがコツです。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次