ASP.NET Core MVCのSEOメタデータ管理:DB設計・TagHelper・サイトマップ実装まで完全ガイド

ASP.NET Core MVC で商用サイトを運用するなら、ページタイトルやメタディスクリプション、canonical、構造化データを「人が運用しやすく」「コードの重複なく」「パフォーマンスよく」管理できるかが検索流入の土台になります。本記事では、データベース中心のSEOメタデータ設計から、TagHelper/ビューコンポーネントによる出力、サイトマップ自動生成、hreflang、Open Graph まで、実装粒度で解説します。

目次

なぜ「データベース管理」なのか

商用サイトでは、キャンペーンや価格改定、ABテストに伴いタイトルやディスクリプションの修正が頻繁に発生します。メタデータをコードや静的ファイルに埋め込むと、変更のたびにデプロイが必要で、マーケターが即日対応できません。メタデータをDBに持たせ、管理画面から編集できるようにしておけば、開発を巻き込まずに高速にPDCAを回せます。さらに多言語・リージョン展開や、重複防止用の canonical 制御、noindex の付け外しなども、DBの一元管理でガバナンスしやすくなります。

主な論点推奨アプローチ補足ポイント
メタデータの保管場所データベースに格納(既存 Pages テーブルに列追加 or SeoMetadata を独立)運用担当が自己完結で編集可。デプロイ不要。履歴管理・承認フローも付けやすい。
コントローラー実装ページ取得時にSEO情報も同時ロードし、ViewData/ViewBag でレイアウトへasync/await + EF Core でI/O待ち最小化。コンパイル済みクエリで最適化。
タグ出力方法共通レイアウトに ビューコンポーネント/タグヘルパー重複排除・逸脱防止。全ページ統一の品質で出力。
canonical URL同一レコードに CanonicalUrl を保有。なければ自動生成のフォールバック。フィルタパラメータ(utm_* など)は除外。重複回避。
構造化データJSON‑LD をDBに格納 or サービスで動的生成Organization / Breadcrumb / Product など用途別にスキーマを用意。
多言語Hreflang を関連テーブルで管理各ロケールのURL・既定言語を保持。リージョン別の正規化。

データモデル設計(EF Core)

ページ本体のコンテンツ(Pages)と、SEOメタ(SeoMetadata)を分ける設計が拡張性・流用性に優れます。カテゴリや商品など複数の「対象タイプ」にメタを付与できるよう、ContentType/ContentId を持たせます。

カラム型/例用途・備考
Idbigint / GUID主キー
ContentTypenvarchar(64) 例: Page, Product対象の種類
ContentIdbigint対象のID(Pages.Id など)
Titlenvarchar(70)ページタイトル(推奨70文字以内)
MetaDescriptionnvarchar(160)メタディスクリプション(推奨120〜160)
MetaRobotsnvarchar(64)index,follow / noindex,nofollow など
CanonicalUrlnvarchar(256)絶対URL推奨。未設定時は自動生成。
OgTitle / OgDescription / OgImageUrlnvarchar / nvarchar / nvarcharOpen Graph / Twitter Card 併用
JsonLdnvarchar(max)JSON‑LD をそのまま保存(用途別テンプレでも可)
UpdatedAt / UpdatedBydatetime2 / nvarchar(64)ガバナンス・承認フロー連携
関連テーブルカラム備考
SeoAlternateSeoMetadataId, Culture, Href, IsDefaulthreflang 出力用。Culture は ja-JP のような IETF タグ。
SeoDefaultsサイト全体のデフォルト値(ブランド名・既定説明文・OG画像など)個別指定が無いときに適用。環境別の noindex もここで制御。
public sealed class SeoMetadata
{
    public long Id { get; set; }
    public string ContentType { get; set; } = default!;
    public long ContentId { get; set; }

    public string? Title { get; set; }
    public string? MetaDescription { get; set; }
    public string MetaRobots { get; set; } = "index,follow";
    public string? CanonicalUrl { get; set; }

    public string? OgTitle { get; set; }
    public string? OgDescription { get; set; }
    public string? OgImageUrl { get; set; }
    public string? TwitterCard { get; set; } = "summary_large_image";

    public string? JsonLd { get; set; }

    public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
    public string? UpdatedBy { get; set; }

    public ICollection<SeoAlternate> Alternates { get; set; } = new List<SeoAlternate>();
}

public sealed class SeoAlternate
{
    public long Id { get; set; }
    public long SeoMetadataId { get; set; }
    public string Culture { get; set; } = default!; // e.g. "ja-JP"
    public string Href { get; set; } = default!;
    public bool IsDefault { get; set; }

    public SeoMetadata SeoMetadata { get; set; } = default!;
}
protected override void OnModelCreating(ModelBuilder b)
{
    b.Entity<SeoMetadata>(e =>
    {
        e.HasIndex(x => new { x.ContentType, x.ContentId }).IsUnique();
        e.Property(x => x.Title).HasMaxLength(70);
        e.Property(x => x.MetaDescription).HasMaxLength(160);
        e.Property(x => x.MetaRobots).HasMaxLength(64);
        e.Property(x => x.CanonicalUrl).HasMaxLength(256);
    });

    b.Entity<SeoAlternate>(e =>
    {
        e.HasIndex(x => new { x.SeoMetadataId, x.Culture }).IsUnique();
        e.Property(x => x.Culture).HasMaxLength(16);
        e.Property(x => x.Href).HasMaxLength(256);
    });
}

取得と出力の責務分離:サービス + TagHelper/VC

「どのページでどのメタを出すか」をコントローラーに散らすと保守負荷が高まります。SEOサービスが路線値やDBを見て最適なメタデータDTOを返し、それをTagHelperまたはビューコンポーネントが<head>に描画、という分離が堅牢です。

public record SeoDto(
    string Title,
    string Description,
    string Robots,
    string Canonical,
    string OgTitle,
    string OgDescription,
    string OgImage,
    string TwitterCard,
    string? JsonLd,
    IReadOnlyList<(string Culture, string Href)> Alternates
);
public interface ISeoService
{
    Task<SeoDto> GetAsync(HttpContext httpContext, string contentType, long contentId, CancellationToken ct);
    Task<SeoDto> GetByRouteAsync(HttpContext httpContext, CancellationToken ct);
}
// 例: ルート値から自動判定し、DB → 既定値 → 自動生成の順でフォールバック
public sealed class SeoService : ISeoService
{
    private readonly ApplicationDbContext _db;
    private readonly LinkGenerator _link;
    private readonly IMemoryCache _cache;
    private readonly SeoDefaults _defaults;

    public SeoService(ApplicationDbContext db, LinkGenerator link, IMemoryCache cache, IOptions<SeoDefaults> defaults)
    {
        _db = db; _link = link; _cache = cache; _defaults = defaults.Value;
    }

    public async Task<SeoDto> GetByRouteAsync(HttpContext http, CancellationToken ct)
    {
        var route = http.GetRouteData();
        var contentType = route.Values["controller"]?.ToString() ?? "Page";
        var contentId = route.Values.ContainsKey("id") ? Convert.ToInt64(route.Values["id"]) : 0;

        // キャッシュキー(URL単位)
        var cacheKey = $"seo:{http.Request.Path}{http.Request.QueryString}";
        if (_cache.TryGetValue(cacheKey, out SeoDto cached)) return cached;

        var dto = await FindSeoAsync(http, contentType, contentId, ct);
        _cache.Set(cacheKey, dto, TimeSpan.FromMinutes(10));
        return dto;
    }

    public async Task<SeoDto> GetAsync(HttpContext http, string contentType, long contentId, CancellationToken ct)
        => await FindSeoAsync(http, contentType, contentId, ct);

    private async Task<SeoDto> FindSeoAsync(HttpContext http, string contentType, long contentId, CancellationToken ct)
    {
        // コンパイル済みクエリ等で最適化しても良い
        var meta = await _db.SeoMetadatas
            .Include(x => x.Alternates)
            .FirstOrDefaultAsync(x => x.ContentType == contentType && x.ContentId == contentId, ct);

        var absoluteCurrent = $"{http.Request.Scheme}://{http.Request.Host}{http.Request.Path}";

        string title = meta?.Title ?? _defaults.Title;
        string desc  = meta?.MetaDescription ?? _defaults.MetaDescription;
        string robots = _defaults.ForceNoIndex ? "noindex,nofollow" : (meta?.MetaRobots ?? "index,follow");
        string canonical = meta?.CanonicalUrl ?? absoluteCurrent;
        string ogt = meta?.OgTitle ?? title;
        string ogd = meta?.OgDescription ?? desc;
        string ogi = meta?.OgImageUrl ?? _defaults.OgImageUrl;
        string tw  = meta?.TwitterCard ?? "summary_large_image";

        var alts = meta?.Alternates?.Select(a => (a.Culture, a.Href)).ToList()
                   ?? new List<(string,string)>();

        return new SeoDto(title, desc, robots, canonical, ogt, ogd, ogi, tw, meta?.JsonLd, alts);
    }
}

TagHelper で<head>を一括生成

[HtmlTargetElement("seo-head")]
public sealed class SeoHeadTagHelper : TagHelper
{
    private readonly ISeoService _service;


public SeoHeadTagHelper(ISeoService service) => _service = service;

[ViewContext] public ViewContext ViewContext { get; set; } = default!;

public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
{
    var dto = await _service.GetByRouteAsync(ViewContext.HttpContext, default);
    var sb = new StringBuilder();
    sb.AppendLine($"<title>{HtmlEncoder.Default.Encode(dto.Title)}</title>");
    sb.AppendLine($"<meta name=\\"description\\" content=\\"{HtmlEncoder.Default.Encode(dto.Description)}\\" />");
    sb.AppendLine($"<meta name=\\"robots\\" content=\\"{dto.Robots}\\" />");
    sb.AppendLine($"<link rel=\\"canonical\\" href=\\"{dto.Canonical}\\" />");

    foreach (var (culture, href) in dto.Alternates)
        sb.AppendLine($"<link rel=\\"alternate\\" hreflang=\\"{culture}\\" href=\\"{href}\\" />");

    sb.AppendLine($"<meta property=\\"og:title\\" content=\\"{HtmlEncoder.Default.Encode(dto.OgTitle)}\\" />");
    sb.AppendLine($"<meta property=\\"og:description\\" content=\\"{HtmlEncoder.Default.Encode(dto.OgDescription)}\\" />");
    if (!string.IsNullOrEmpty(dto.OgImage))
        sb.AppendLine($"<meta property=\\"og:image\\" content=\\"{dto.OgImage}\\" />");
    sb.AppendLine($"<meta name=\\"twitter:card\\" content=\\"{dto.TwitterCard}\\" />");

    if (!string.IsNullOrWhiteSpace(dto.JsonLd))
        sb.AppendLine($"<script type=\\"application/ld+json\\">{dto.JsonLd}</script>");

    output.TagName = null; // 親タグを消す
    output.Content.SetHtmlContent(sb.ToString());
}


}

_Layout.cshtml への組み込み

<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <seo-head></seo-head>
    @RenderSection("Head", required: false)
</head>

TagHelper が好みでなければ、同等のビューコンポーネント(<vc:seo-head />)に置き換えてもOKです。

コントローラー側の粒度:最小限の責務だけ

コントローラーは「どのコンテンツが開かれたか」を決めるだけ。SEOはサービスに任せます。必要なら ViewData に上書き値を一時的に入れるフックも用意します。

public class PagesController : Controller
{
    private readonly ApplicationDbContext _db;
    public PagesController(ApplicationDbContext db) => _db = db;


public async Task<IActionResult> Detail(long id)
{
    var page = await _db.Pages.FindAsync(id);
    if (page == null) return NotFound();

    // 特定ページだけ一時的にタイトルを上書きしたい場合
    // ViewData["Seo:TitleOverride"] = $"{page.Title} | 冬のキャンペーン";

    return View(page);
}


}

canonical と URL 正規化の考え方

canonical は「検索結果で代表としたいURL」を指します。クエリ文字列(utm_*, sort など)を除去し、HTTPS・www有無・末尾スラッシュの方針を統一しましょう。自動生成の際は LinkGenerator で絶対URLを作り、DBに明示があればそれを優先します。

パターン正規化方針備考
http -> https301リダイレクトセキュリティと重複防止
example.com と www.example.comどちらかへ301で統一canonicalの一貫性
末尾スラッシュ有無を統一内部リンクも合わせる
追跡クエリcanonical から除外utm_source など
ページネーション各ページ自身をcanonicalrel=prev/next は現在非推奨扱い

robots 制御:ヘッダーかメタか

インデックスさせたくないページ(検索結果、管理画面、テスト環境など)は、<meta name=”robots”> か HTTP ヘッダー X-Robots-Tag のいずれかで制御します。ファイルタイプ(PDFなど)にはヘッダーが便利です。環境変数でステージングだけ noindex を全体適用する仕組みも現実的です。

// 環境が Staging のとき X-Robots-Tag を全ページに付与するミドルウェア
app.Use(async (ctx, next) =>
{
    await next();
    if (app.Environment.IsStaging())
    {
        ctx.Response.Headers["X-Robots-Tag"] = "noindex,nofollow";
    }
});

サイトマップ /sitemap.xml を自動生成

DBの公開ページを列挙して XML を生成します。更新頻度が高いサイトは、出力をキャッシュし、編集時に無効化する設計が良いです。

[ResponseCache(Duration = 3600, Location = ResponseCacheLocation.Any)]
[Route("sitemap.xml")]
public class SitemapController : Controller
{
    private readonly ApplicationDbContext _db;
    private readonly LinkGenerator _link;

    public SitemapController(ApplicationDbContext db, LinkGenerator link)
    { _db = db; _link = link; }

    [HttpGet]
    public async Task<IActionResult> Index()
    {
        var urls = await _db.Pages
            .Where(p => p.IsPublished)
            .Select(p => new { p.Slug, p.UpdatedAt })
            .ToListAsync();

        var sb = new StringBuilder();
        sb.Append("<?xml version=\\"1.0\\" encoding=\\"UTF-8\\"?>");
        sb.Append("<urlset xmlns=\\"http://www.sitemaps.org/schemas/sitemap/0.9\\">");
        foreach (var u in urls)
        {
            var loc = _link.GetUriByAction(HttpContext, action: "Detail", controller: "Pages", values: new { slug = u.Slug })!;
            sb.Append($"<url><loc>{loc}</loc><lastmod>{u.UpdatedAt:yyyy-MM-dd}</lastmod></url>");
        }
        sb.Append("</urlset>");
        return Content(sb.ToString(), "application/xml", Encoding.UTF8);
    }
}

構造化データ(JSON‑LD)の設計例

共通のサイト全体用 Organization、パンくずの BreadcrumbList、そしてコンテンツ種別(記事・商品・FAQ 等)に応じたスキーマを用意します。DBに丸ごと保存してもよいし、サービス側でDTOから動的生成してもOKです。

{
  "@context": "https://schema.org",
  "@type": "Organization",
  "name": "Example株式会社",
  "url": "https://www.example.jp/",
  "logo": "https://www.example.jp/assets/logo.png",
  "sameAs": [
    "https://www.example.jp/social"
  ]
}
// DTOからProductのJSON-LDを組み立てる例
var jsonLd = new
{
    @context = "https://schema.org",
    @type = "Product",
    name = product.Name,
    image = product.MainImageUrl,
    description = product.ShortDescription,
    sku = product.Sku,
    offers = new {
        @type = "Offer",
        url = currentUrl,
        priceCurrency = "JPY",
        price = product.Price.ToString("0.##"),
        availability = "https://schema.org/InStock"
    }
};
var json = JsonSerializer.Serialize(jsonLd);

多言語・hreflang の落とし穴と実装

多言語サイトでは、各ロケールURLを SeoAlternate テーブルに保存し、TagHelper が <link rel="alternate" hreflang="xx-YY"> を出力します。言語圏のトップ(既定)ページには hreflang="x-default" を用意します。URLの構造(サブディレクトリ / サブドメイン / ccTLD)を決め、内部リンクとcanonicalも合わせて統一してください。

管理画面の最小要件

運用担当が迷わないUIが重要です。以下のようなフォームを最低限用意しましょう。

  • タイトル(残り文字数カウンタ表示)
  • メタディスクリプション(残り文字数カウンタ)
  • robots(プルダウン:index/follow, noindex/nofollow など)
  • canonical URL(空なら自動)
  • OG/Twitter(タイトル・ディスクリプション・画像)
  • JSON‑LD(スキーマのテンプレートから生成/編集)
  • hreflang の追加・削除(カルチャとURL)
<form method="post">
  <label>タイトル<input name="Title" maxlength="70" /></label>
  <label>メタディスクリプション<textarea name="MetaDescription" maxlength="160"></textarea></label>
  <label>Robots
    <select name="MetaRobots">
      <option>index,follow</option>
      <option>noindex,nofollow</option>
      <option>noindex,follow</option>
    </select>
  </label>
  <label>Canonical URL<input name="CanonicalUrl" /></label>
  <fieldset>
    <legend>Open Graph / Twitter</legend>
    <label>OG Title<input name="OgTitle" /></label>
    <label>OG Description<textarea name="OgDescription"></textarea></label>
    <label>OG Image URL<input name="OgImageUrl" /></label>
  </fieldset>
  <label>JSON‑LD<textarea name="JsonLd"></textarea></label>
  <button type="submit">保存</button>
</form>

キャッシュ設計と無効化

アクセス毎にDBを引くとレイテンシが悪化します。IMemoryCache / IDistributedCache で URL単位・コンテンツ単位でキャッシュし、編集時にピンポイントで無効化しましょう。シンプルな方法は、SeoMetadata 更新時に SeoVersion のようなカウンタをインクリメントし、サービス側のキャッシュキーにバージョンを含めるやり方です。

キャッシュ対象キー例失効タイミング
URL別のSEO DTOseo:/products/123?ver=42SeoVersion更新時/対象IDの更新時
サイトマップXMLsitemap:xml公開・非公開の切替時
既定OG画像URLseo:defaultsブランド資産更新時

パフォーマンス(Core Web Vitals)とSEO

  • LCP: メイン画像に preload、サーバ側で適切なサイズを生成、HTTP圧縮を有効化。
  • CLS: 画像の幅・高さ、広告枠の固定、フォントの FOUT/FORB 対策。
  • INP: 重いJSの遅延読み込み、サーバ側レンダリング(SSR)で初期描画を高速化。
  • 静的ファイルの長期キャッシュ+バージョニング(asp-append-version)。
  • OutputCache/ResponseCaching を要件に応じて採用。

SEOベストプラクティス(要点)

  • 見出しタグで意図を明示。本文はユーザー課題をまず解決。
  • 重複ページには canonical。パラメータは整理。
  • ログイン・カート・検索結果などは noindex。
  • パンくずリストを実装し、JSON‑LD も出力。
  • 内部リンクとアンカーテキストを最適化。
  • 404/410 の適切な応答。削除は 410 で明確化。
  • 画像には代替テキスト、PageSpeed の警告に継続対応。

実装手順のひな型

  1. DBに SeoMetadata / SeoAlternate / SeoDefaults 追加・マイグレーション。
  2. ISeoService と実装(DB→既定→自動生成のフォールバック)。
  3. TagHelper(またはVC)で <head> にメタを描画。
  4. _Layout に組み込み、不要ページに X-Robots-Tag を付与するミドルウェアを用意。
  5. サイトマップと robots.txt をエンドポイント化(キャッシュを忘れずに)。
  6. 管理画面で編集・承認ワークフローを整備。監査ログを残す。
  7. CI/CDでステージングは noindex を強制。プロダクションのみ解除。

コード断片:robots.txt と環境別制御

app.MapGet("/robots.txt", (IWebHostEnvironment env) =>
{
    var sb = new StringBuilder();
    sb.AppendLine("User-agent: *");
    if (env.IsStaging())
    {
        sb.AppendLine("Disallow: /");
    }
    else
    {
        sb.AppendLine("Disallow: /admin/");
        sb.AppendLine("Disallow: /signin");
        sb.AppendLine("Sitemap: https://www.example.jp/sitemap.xml");
    }
    return Results.Text(sb.ToString(), "text/plain", Encoding.UTF8);
});

Open Graph / Twitter Card を活用

検索順位に直接影響は小さくとも、SNSでのCTRを左右します。DBにOG/Twitterの上書き値を持たせ、画像は推奨サイズ(1200×630 など)でCDN配信します。未設定時は既定画像へフォールバック。

動的リスト・絞り込みページの取り扱い

商品一覧などの絞り込みページは、条件の組み合わせが無数に増殖し重複リスクが高い領域です。方針は以下のいずれか:

  • 代表となるディレクトリ(カテゴリトップ)のみ index、絞り込み条件は noindex,follow。
  • 主要条件だけを許可(ブランドや価格帯など)、それ以外は noindex。
  • ページネーションは各ページ自身をcanonical。不要なクエリは削除。

テスト自動化:SEOの回 regress を防ぐ

統合テストで、各URLに必須タグが出ているかを検証します。AngleSharp などでHTMLを解析し、title/description/canonical/robots/OG/JSON‑LD の存在と内容をチェックします。

public class SeoIntegrationTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly HttpClient _client;
    public SeoIntegrationTests(WebApplicationFactory<Program> factory) => _client = factory.CreateClient();

    [Fact]
    public async Task Home_Should_Render_Head_Tags()
    {
        var html = await _client.GetStringAsync("/");
        var doc = await BrowsingContext.New(Configuration.Default.WithDefaultLoader()).OpenAsync(req => req.Content(html));
        Assert.NotNull(doc.Head.QuerySelector("title"));
        Assert.NotNull(doc.Head.QuerySelector("meta[name='description']"));
        Assert.NotNull(doc.Head.QuerySelector("link[rel='canonical']"));
    }
}

運用フローとガバナンス

ステップ担当要点
草案作成コンテンツ本文優先。タイトルは検索意図に即して端的に。
メタ編集マーケ規定フォーマット・禁止語・記号のルールに従う。
レビューSEO/法務重複/誇大表現/商標チェック。
公開運用サイトマップ再生成・キャッシュ無効化。
計測SEO/データCTR/掲載順位/コンバージョンで評価。

よくある失敗と対策

  • 環境混線:ステージングのURLがインデックスされる → X-Robots-Tag: noindex を環境強制。
  • タグ重複:Viewごとにtitleを出してしまい二重化 → <head> はTagHelper/VCに一元化。
  • 巨大なJSON‑LD:ページ初期表示が重くなる → 必要な型・プロパティのみに絞り、圧縮。
  • OG画像の404:メディア削除でシェア崩れ → 既定画像へフォールバック+リンク切れ検知。
  • canonical の相互矛盾:ページAがBを指し、BがAを指す → 自動生成時に相互チェックを行う。

セキュリティ・権限設計

  • メタ更新の権限はロール(Editor/Publisher)で分離。
  • 監査ログ(誰がいつ何を変えたか)を UpdatedBy/UpdatedAt と履歴テーブルで永続化。
  • OG画像URLや外部リンクの入力はサーバ側でバリデーション。

チェックリスト(公開前)

  • タイトル・ディスクリプションの空欄なし/文字数範囲内。
  • canonical は正規URLで、ページ固有。
  • 重要ページに構造化データが適用されている。
  • 多言語ページに hreflang と x-default がある。
  • サイトマップが最新で404を含まない。
  • ステージングが noindex である。
  • Core Web Vitals がしきい値内。

サンプル:DBにない場合の自動生成ロジック

private static string AutoTitleFallback(string? h1, string siteName)
{
    if (!string.IsNullOrWhiteSpace(h1))
        return $"{h1} | {siteName}";
    return siteName;
}

private static string AutoDescriptionFallback(string? body)
{
if (string.IsNullOrWhiteSpace(body)) return "公式サイト。最新情報・サービスのご案内。";
var text = Regex.Replace(body, "<.*?>", string.Empty);
return text.Length > 150 ? text.Substring(0, 150) + "…" : text;
}

まとめ:DB中心 + 共通出力 + 自動化で強いSEO基盤に

ASP.NET Core MVC のSEOは、DB中心の柔軟なメタ管理、TagHelper/VCでの一元出力、canonical/hreflang/JSON‑LD/サイトマップの自動化、そしてキャッシュとパフォーマンス最適化が揃うと、運用と品質の両立ができます。少しの初期実装で、以後の運用コストとリスクが大きく下がります。

付録:ミニ実装パッケージ構成例

Application/
  Services/
    ISeoService.cs
    SeoService.cs
  TagHelpers/
    SeoHeadTagHelper.cs
  Controllers/
    SitemapController.cs
  Data/
    Entities/
      SeoMetadata.cs
      SeoAlternate.cs
    ApplicationDbContext.cs
  Views/
    Shared/
      _Layout.cshtml
      Components/
        SeoHead/
          Default.cshtml (※VC版を使う場合)
Admin/
  Controllers/SeoController.cs
  Views/Seo/Edit.cshtml

付録:推奨バリデーションルール

項目ルール理由
Title70文字以内・末尾にブランド統一検索結果での切れ防止・指名検索の強化
MetaDescription120〜160文字CTR最大化
CanonicalUrlhttps絶対URL・末尾スラッシュ統一重複と評価分散の回避
OgImageUrl推奨サイズで存在チェックシェア時の見栄え保証
JsonLdJSONとして正当・スキーマに準拠リッチリザルト狙い

付録:よく使う正規化ミドルウェア

app.Use(async (ctx, next) =>
{
    var host = ctx.Request.Host.Value;
    var isWww = host.StartsWith("www.");
    var isHttp = ctx.Request.Scheme == "http";
    var path = ctx.Request.Path.Value ?? "/";


// 末尾スラッシュ統一(例:付ける方針)
if (!path.EndsWith("/")) path += "/";

if (isHttp || !isWww)
{
    var url = $"https://www.{host.TrimStart("www.".ToCharArray())}{path}";
    ctx.Response.Redirect(url, permanent: true);
    return;
}
await next();


});

ホスト名の方針や末尾スラッシュの有無は、自社ルールに合わせて調整してください。

付録:公開後のモニタリング

  • クローラビリティ:クロールエラー・インデックスカバレッジ・サイトマップ送信状況。
  • 掲載結果:検索クエリごとのCTR・順位・表示回数。
  • 技術指標:LCP/CLS/INP の推移。デプロイとの相関を追跡。
  • ヘルスチェック:/health に AspNetCore.HealthChecks を導入し、監視と連携。

最後に:一般論にとどまらない実務の勘所

  • 「まず記事」ではなく「まず設計」。後からのスケールが段違い。
  • “自動生成できるところ”を増やす。人手は本文に集中させる。
  • SEOは“継続運用”。DB中心設計は継続改善のための投資。

この記事を書いた人

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

コメント

コメントする

目次