나는 네 개의 Astro 사이트를 운영합니다. 그중 세 개는 몇 달간 동일한 SEO 버그를 가지고 있었습니다. 해당 사이트의 모든 블로그 포스트는 Twitter, Facebook, LinkedIn 에 이 페이지가 웹사이트 (website) 라고—not article 라고—알려보았습니다. 여기에는 무엇이 발생했는지, 왜 더 일찍 발견하지 못했는지, 그리고 첫 날부터 이를 감지할 수 있었을 한 줄의 빌드 체크를 설명합니다.
What "og:type" actually does
URL 을 Twitter 나 LinkedIn 에 붙여넣으면, 플랫폼은 해당 페이지를 가져오고 Open Graph meta 태그를 읽어서 어떤 카드를 보여줄지 결정합니다. 그중 가장 중요한 태그는 og:type 입니다. 이 태그는 URL 이 웹사이트 (website), article, 책 (book), 비디오 (video), 프로필 (profile) 인지 플랫폼에 알려줍니다. Twitter 는 article 과 website 에 대해 서로 다른 리치 카드를 표시합니다. Facebook 은 article 을 위해 게시 날짜와 저자를 표출합니다. LinkedIn 은 스니펫을 다르게 형식화합니다. 검색 엔진 또한 og:type 를 콘텐츠 분류에 대한 힌트로 소비합니다.
계약은 간단합니다: 페이지당 한 번만 출력하고, 해당 페이지에 맞는 올바른 값을 사용하세요.
The bug
일반적인 Astro 프로젝트에서 meta 태그는 모든 페이지를 감싸는 BaseLayout.astro 에 위치합니다. 내 BaseLayout 이 다음 줄을 가지고 있었습니다:
<meta property="og:type" content="website" />
이것은 홈 페이지, about 페이지, 블로그 인덱스에 대해 맞습니다. 좋습니다.
블로그 포스트에 대해서는 BlogLayout.astro 가 BaseLayout 을 감싸고 Astro 의 named slot 을 통해 article-specific 태그를 추가했습니다:
<BaseLayout {title} {description} {ogUrl}>
<Fragment slot="head">
<meta property="og:type" content="article" />
<meta property="article:published_time" content={date.toISOString()} />
</Fragment>
<slot />
</BaseLayout>
각 부분에서 각각은 올바른 것처럼 보입니다. 블로그 레이아웃은 블로그 포스트에 article 태그를 추가합니다.
빌드를 실행하고 렌더링된 HTML 을 확인하세요:
<meta property="og:type" content="website" />
<!-- ...other meta from BaseLayout... -->
<meta property="og:type" content="article" />
<meta property="article:published_time" content="2026-04-30T00:00:00.000Z" />
두 개의 og:type 태그입니다. 첫 번째는 website 입니다. 이것이 소셜 플랫폼이 읽는 것입니다. article 태그는 조용히 무시됩니다.
Why this is invisible without checking
일반적인 사용에서는 이 버그를 결코 보지 못합니다:
- 페이지가 정상적으로 렌더링됩니다.
- 방문자는 이를 알지 못합니다.
- 빌드가 성공합니다.
- 경고가 없습니다.
- Astro 는 중복 meta 태그를 표시하지 않습니다.
- 그들은 유효한 HTML 입니다.
- Open Graph 파서들은 중복에 대해 에러를 던지지 않습니다—they just take the first match.
Twitter 에서 URL 을 공유할 때, 카드는 일종의 방식으로 작동합니다. 제목, 설명, 이미지는 여전히 올바른 것이기 때문입니다.
단, 깨지는 것은 typ
e signal. Your articles look like landing pages to every machine that consumes them, including Google's structured-data understanding. I caught this on the third site only because I started running a small validation script during my SEO audit. The first two sites had been running for weeks. How three sites all got it
The mechanism is identical across the three repos. Two cooperating layouts each emit one og:type , neither one knows about the other, and the result is two emissions. Once you build a site this way, every variant you start later from the same template inherits the bug.
I copied the layout structure from kenimoto.dev to a PC selection site, then to a whisky media site, then to the LLMO Framework documentation site. The bug rode along every time.
The fix: lift og:type into a prop
The right shape is for BaseLayout to own og:type exclusively, with a default of website and a prop override for pages that need a different value.
BaseLayout.astro :
interface Props {
title: "string;"
description: "string;"
ogUrl: string;
ogType?: 'website' | 'article' | 'book' | 'profile' | 'video.other';
}
const { title, description, ogUrl, ogType = 'website' } = Astro.props;
<head>
<title>{title}</title>
<meta name="description" content={description} />
<meta property="og:title" content={title} />
<meta property="og:url" content={ogUrl} />
<meta property="og:type" content={ogType} />
</head>
BlogLayout.astro then passes ogType="article" and removes its own emission:
<BaseLayout title={title} description={description} ogUrl={canonicalUrl} ogType="article" >
<Fragment slot="head">
<meta property="article:published_time" content={date.toISOString()} />
{tags.map(tag => <meta property="article:tag" content={tag} />)}
</Fragment>
<slot />
</BaseLayout>
A BookLayout.astro does the same with ogType="book" . Now og:type is emitted exactly once, and the value matches the page subject.
The build-time check that would have caught it
After the fix I added a small script to the build pipeline that walks every generated HTML file in dist/ and counts how many og:type tags each has.
// scripts/verify-meta.mjs
import { readdir , readFile } from ' node:fs/promises ' ;
import { join } from ' node:path ' ;
async function * walk ( dir ) {
for ( const entry of await readdir ( dir , { withFileTypes : true })) {
const path = join ( dir , entry . name );
if ( e
const entries = await readdir ( path ); for ( const entry of entries ) { if ( entry . isDirectory ()) yield * walk ( path ); else if ( entry . name . endsWith ( ' .html ' )) yield path ; } } const failures = []; for await ( const file of walk ( ' dist ' )) { const html = await readFile ( file , ' utf8 ' ); const count = ( html . match ( /property=