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 を持たせます。
| カラム | 型/例 | 用途・備考 |
|---|---|---|
| Id | bigint / GUID | 主キー |
| ContentType | nvarchar(64) 例: Page, Product | 対象の種類 |
| ContentId | bigint | 対象のID(Pages.Id など) |
| Title | nvarchar(70) | ページタイトル(推奨70文字以内) |
| MetaDescription | nvarchar(160) | メタディスクリプション(推奨120〜160) |
| MetaRobots | nvarchar(64) | index,follow / noindex,nofollow など |
| CanonicalUrl | nvarchar(256) | 絶対URL推奨。未設定時は自動生成。 |
| OgTitle / OgDescription / OgImageUrl | nvarchar / nvarchar / nvarchar | Open Graph / Twitter Card 併用 |
| JsonLd | nvarchar(max) | JSON‑LD をそのまま保存(用途別テンプレでも可) |
| UpdatedAt / UpdatedBy | datetime2 / nvarchar(64) | ガバナンス・承認フロー連携 |
| 関連テーブル | カラム | 備考 |
|---|---|---|
SeoAlternate | SeoMetadataId, Culture, Href, IsDefault | hreflang 出力用。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 -> https | 301リダイレクト | セキュリティと重複防止 |
| example.com と www.example.com | どちらかへ301で統一 | canonicalの一貫性 |
| 末尾スラッシュ | 有無を統一 | 内部リンクも合わせる |
| 追跡クエリ | canonical から除外 | utm_source など |
| ページネーション | 各ページ自身をcanonical | rel=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 DTO | seo:/products/123?ver=42 | SeoVersion更新時/対象IDの更新時 |
| サイトマップXML | sitemap:xml | 公開・非公開の切替時 |
| 既定OG画像URL | seo: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 の警告に継続対応。
実装手順のひな型
- DBに
SeoMetadata/SeoAlternate/SeoDefaults追加・マイグレーション。 ISeoServiceと実装(DB→既定→自動生成のフォールバック)。- TagHelper(またはVC)で <head> にメタを描画。
- _Layout に組み込み、不要ページに
X-Robots-Tagを付与するミドルウェアを用意。 - サイトマップと robots.txt をエンドポイント化(キャッシュを忘れずに)。
- 管理画面で編集・承認ワークフローを整備。監査ログを残す。
- 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
付録:推奨バリデーションルール
| 項目 | ルール | 理由 |
|---|---|---|
| Title | 70文字以内・末尾にブランド統一 | 検索結果での切れ防止・指名検索の強化 |
| MetaDescription | 120〜160文字 | CTR最大化 |
| CanonicalUrl | https絶対URL・末尾スラッシュ統一 | 重複と評価分散の回避 |
| OgImageUrl | 推奨サイズで存在チェック | シェア時の見栄え保証 |
| JsonLd | JSONとして正当・スキーマに準拠 | リッチリザルト狙い |
付録:よく使う正規化ミドルウェア
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中心設計は継続改善のための投資。

コメント