SEO-IMPROVEMENT-PLAN.md

SEO Improvement Plan — amerikvitamin.mn

Scope: Code changes only in vit-store repo. No backlink outreach, no content writing, no off-page SEO. Target: apps/storev2/ (storefront), packages/api/ (tRPC + DB queries), packages/api/src/db/schema.ts (schema). Goal: Rank for product names (e.g. "vitamin d3", "magnesium glycinate"), brand names (e.g. "now foods", "naturebell"), and category keywords in Mongolian Google search.


Context from audit findings

What's already good (don't touch)

  • Astro v5 SSR with Cloudflare adapter, TTFB 55ms
  • SEO.astro component with OG tags, Twitter cards, Organization + WebSite schema
  • Product pages have Product, Offer, Brand, BreadcrumbList JSON-LD
  • Category pages have BreadcrumbList + ItemList schema
  • FAQ page has FAQPage schema
  • Sitemap via @astrojs/sitemap, robots.txt blocks AI crawlers + private pages
  • Images use WebP from CDN with lazy loading + alt text
  • oldSlugs + 301 redirect infrastructure already exists (issue #78)

What's broken / missing

  1. Product URLs are 150-196 chars — slug = brand.name + name + potency + amount slugified. Example: a-c-grace-company-unique-e-high-delta-select-tocotrienol-concentrate-optimum-complex-for-maximum-health-benefits-tocopherol-free-60-easy-to-swallow-softgels-7187
  2. Product <title> = raw English product name — no Mongolian, no product-type keyword. Mongolian users search "витамин д3", not "tocotrienol concentrate"
  3. name_mn, seoTitle, seoDescription columns exist in schema but are NOT used in storefront rendering. Product page ignores them entirely.
  4. Category slugs are transliterated Cyrillic — darkhlaa-demjikh instead of immune-support. Not searchable.
  5. No visible breadcrumbs on product pages — BreadcrumbList schema exists but no visual breadcrumb nav rendered (actually it IS rendered via <Breadcrumb> component — verify it matches schema)
  6. No aggregateRating / review schema — Product schema has Offer but no ratings
  7. Sitemap lastmod is new Date() for all URLs — all identical, Google ignores it
  8. trailingSlash: "ignore" in astro.config — causes /products vs /products/ duplicate canonical issues (confirmed in GSC)
  9. No hreflang or geo-targeting — US gets 408 impressions with 0.2% CTR
  10. No blog/content system — 95% of traffic is brand-name searches

GSC data summary

  • 357 clicks, 2,200 impressions (Mar 2025 – Jun 2026)
  • Top queries: "amerik vitamin" (45 clicks), "америк витамин" (34), "америк витамин худалдаа" (17)
  • Only 1 product page got a click from a non-brand query
  • Homepage gets 73% of all clicks
  • Mobile: 71% of clicks, Mongolia: 99% of clicks
  • Upward trend: ~7 clicks/day in May 2026

SERP gaps (verified via OpenSEO live SERP fetch)

  • "магни глицинат" — NO Mongolian site ranks (you sell Naturebell Magnesium Glycinate)
  • "витамин д3 к2" — eMonos #2, you're absent (you sell 3+ D3+K2 products)
  • "коллаген хэрэглэх" — eMonos #1 with thin category page
  • "пробиотик" — eMonos #1, apteka.mn #3, you're absent

Phase 1: Fix product page SEO (highest impact)

1.1 Use seoTitle / seoDescription / name_mn in product page

File: apps/storev2/src/pages/products/[slug].astro

Currently line 110-113:

ts
1const seoTitle = product.name;
2const seoDescription = product.description
3 ? truncateAtWord(product.description, 155)
4 : `${product.name} - ${product.brand?.name || "АНУ"} брэндийн витамин...`;

Change to:

ts
1const seoTitle = product.seoTitle || product.name_mn || product.name;
2const seoDescription = product.seoDescription
3 || (product.description ? truncateAtWord(product.description, 155) : null)
4 || `${product.name_mn || product.name} - ${product.brand?.name || "АНУ"} брэндийн витамин, нэмэлт тэжээл. Америк Витамин дэлгүүрээс захиалаарай.`;

Also update the <h1> in product-info.astro to show name_mn if available, with the English name as a subtitle.

File: apps/storev2/src/components/product/product-info.astro

Currently the h1 renders product.name. Change to:

astro
1<h1 ...>
2 {product.name_mn || product.name}
3</h1>
4{product.name_mn && product.name_mn !== product.name && (
5 <p class="text-sm text-muted-foreground font-medium mt-1">{product.name}</p>
6)}

File: packages/api/src/queries/products/store.ts — getProductById query

Add seoTitle, seoDescription, name_mn to the selected columns if not already returned (they're in the schema but verify the query selects them). Currently the query selects name, slug, price, status, stock, description, discount, amount, potency, dailyIntake, categoryId, brandId, ingredients, ... — check if seoTitle, seoDescription, name_mn are included. If not, add them.

1.2 Shorten product URL slugs

File: packages/api/src/routers/admin/product.ts (lines 144-148)

Currently:

ts
1const productName = `${brand.name} ${input.name} ${input.potency} ${input.amount}`;
2const slug = productName
3 .toLowerCase()
4 .replace(/[^a-z0-9]+/g, "-")
5 .replace(/^-+|-+$/g, "");

This produces 150+ char slugs. Change slug generation to a short format:

ts
1const productName = `${brand.name} ${input.name} ${input.potency} ${input.amount}`;
2// Short slug: first 3 significant words from name + potency + brand prefix
3const shortName = input.name
4 .toLowerCase()
5 .replace(/[^a-z0-9\s]/g, "")
6 .split(/\s+/)
7 .filter(w => w.length > 2 && !["the", "and", "for", "with", "per", "serving"].includes(w))
8 .slice(0, 4)
9 .join("-");
10const brandPrefix = brand.name.toLowerCase().replace(/[^a-z0-9]/g, "").slice(0, 10);
11const slug = `${shortName}-${brandPrefix}`;

This produces slugs like vitamin-d3-k2-2000-microingr (~35 chars) instead of 196 chars.

For existing products: Write a one-time migration script that:

  1. Reads all products from DB
  2. Generates new short slugs
  3. Appends current slug to oldSlugs array
  4. Updates slug column
  5. The existing 301 redirect infrastructure in [slug].astro (lines 34-42, 55-57) will automatically redirect old URLs

Migration script location: apps/server/src/scripts/regenerate-slugs.ts (new file)

1.3 Add aggregateRating placeholder to Product schema

File: apps/storev2/src/pages/products/[slug].astro

Add to productSchema object (after offers):

ts
1...(product.reviewCount > 0 && product.avgRating ? {
2 aggregateRating: {
3 "@type": "AggregateRating",
4 ratingValue: product.avgRating,
5 reviewCount: product.reviewCount,
6 bestRating: "5",
7 worstRating: "1",
8 }
9} : {}),

This requires adding reviewCount and avgRating to the product query (nullable, return 0/null if no reviews). Even without a review system yet, the schema field can be added once reviews exist. Skip this if no review data exists — fake ratings violate Google's guidelines.

Alternative: Add a review system later. For now, skip aggregateRating.

1.4 Add Mongolian description section to product page

File: apps/storev2/src/components/product/product-details.astro

In the "description" tab, if name_mn exists, prepend a Mongolian summary before the English description:

astro
1{hasDescription ? (
2 <div class="prose ...">
3 {product.name_mn && (
4 <p class="text-sm sm:text-base leading-relaxed font-bold border-b-2 border-border pb-4 mb-4">
5 {product.name_mn}
6 </p>
7 )}
8 <p class="text-sm sm:text-base leading-relaxed font-medium whitespace-pre-line">
9 {product.description}
10 </p>
11 </div>
12) : ...}

This requires passing name_mn through to ProductDetails component props.


Phase 2: Fix category page SEO

2.1 Fix category slugs (transliterated → English)

Problem: Category slugs are transliterated Cyrillic: darkhlaa-demjikh, erunkhii-vitamin-ba-nemelt. No one searches these.

Solution: Update category slugs in the database to English equivalents:

Current slugNew slugCategory name (MN)
magni-ba-erdesmagnesium-mineralsМагни ба Эрдэс
erunkhii-vitamin-ba-nemeltmultivitamin-supplementsЕрөнхий Витамин ба Нэмэлт
urgamlyn-khand-ba-antioksidantherbal-antioxidantsУргамлын Ханд ба Антиоксидант
vitamin-dvitamin-d(already OK)
gedes-ba-probiotikprobiotics-gut-healthГэдэс ба Пробиотик
khuukhdiin-eruul-mendkids-healthХүүхдийн Эрүүл Мэнд
emegteichuudiin-eruul-mendwomens-healthЭмэгтэйчүүдийн Эрүүл Мэнд
vitamin-cvitamin-c(already OK)
kollagen-ba-ue-muchcollagen-jointsКоллаген ба Үе Мөч
omega-ba-toson-khuurilomega-fatty-acidsОмега ба Тосон Хүчил
tarhi-ba-medrelbrain-nervousТархи ба Мэдрэл
eleg-ba-khorguijuuleltliver-detoxЭлэг ба Хоргүйжүүлэлт
vitamin-b-tsogtsvitamin-b-complexВитамин B Цогц
darkhlaa-demjikhimmune-supportДархлаа Дэмжих
noir-ba-taivshralsleep-relaxationНойр ба Тайвшрал
eregteichuudiin-eruul-mendmens-healthЭрэгтэйчүүдийн Эрүүл Мэнд
ars-us-khumsskin-hair-nailsАрьс Үс Хумс
sport-ba-amin-khuurilsports-amino-acidsСпорт ба Амин Хүчил

Implementation: SQL migration or admin script. Add old slugs to a oldSlugs column on CategoriesTable (doesn't exist yet — needs schema addition + migration). Add 301 redirect in products/category/[slug].astro for old slugs.

File: packages/api/src/db/schema.ts — add oldSlugs: jsonb("old_slugs").default(sql'[]'::jsonb).notNull() to CategoriesTable.

File: apps/storev2/src/pages/products/category/[slug].astro — add redirect logic similar to product page:

ts
1if (category.oldSlugs?.includes(slug)) {
2 return Astro.redirect(`/products/category/${category.slug}/1/`, 301);
3}

Actually simpler: the getCategoryBySlug query should check both slug and oldSlugs. If found via oldSlug, redirect.

2.2 Add Mongolian SEO content to category pages

File: apps/storev2/src/pages/products/category/[slug]/[page].astro

The category page already uses category.seoTitle, category.seoDescription, category.description with fallbacks. The issue is these fields are likely empty in the database.

Action: Populate seoTitle, seoDescription, and description for all 18 categories via admin panel or SQL migration. Example for "Магни ба Эрдэс":

  • seoTitle: "Магни, эрдэс бодис — АНУ-ын витамин | Америк Витамин"
  • seoDescription: "Магни глицинат, цитрат, малтат магни зэрэг АНУ-ын шилдэг брэндүүдийн эрдэс бодис, нэмэлт тэжээл. Naturebell, Now Foods, Micro Ingredients брэндээс сонгоно уу."
  • description: 2-3 sentences about magnesium benefits in Mongolian

This is a data task, not a code change — but the code already supports it. The agent should create a SQL migration script to populate these fields.

2.3 Add CollectionPage schema to category pages

File: apps/storev2/src/pages/products/category/[slug]/[page].astro

Currently has BreadcrumbList + ItemList. Add CollectionPage schema:

ts
1const collectionPageSchema = {
2 "@context": "https://schema.org",
3 "@type": "CollectionPage",
4 name: category.name,
5 description: seoDescription,
6 url: `${SITE_URL}/products/category/${slug}/1/`,
7 mainEntity: {
8 "@type": "ItemList",
9 numberOfItems: pagination.totalCount,
10 itemListElement: products.map((p, i) => ({
11 "@type": "ListItem",
12 position: (page - 1) * PAGE_SIZE + i + 1,
13 url: `${SITE_URL}/products/${p.slug}-${p.id}/`,
14 name: p.name,
15 })),
16 },
17};

Replace the separate itemListSchema with this combined version.


Phase 3: Fix brand page SEO

3.1 Add brand schema and SEO fields

File: apps/storev2/src/pages/products/brand/[slug]/[page].astro

Brand pages already use brand.seoTitle, brand.seoDescription, brand.description with fallbacks. Same as categories — these are likely empty in DB.

Action: Populate seoTitle, seoDescription, description for top brands via SQL migration. Priority brands (by GSC impressions):

  1. Nordic Naturals (5 clicks, 18 impr)
  2. TruHeight (5 clicks, 13 impr)
  3. Nature's Nutrition (3 clicks, 16 impr)
  4. Naturewise (3 clicks, 16 impr)
  5. Bronson (2 clicks, 21 impr)

Add Brand schema to brand pages:

ts
1const brandSchema = {
2 "@context": "https://schema.org",
3 "@type": "Brand",
4 name: brand.name,
5 description: brandIntro,
6 url: `${SITE_URL}/products/brand/${slug}/1/`,
7 ...(brandLogoUrl ? { logo: brandLogoUrl } : {}),
8};

3.2 Fix brand page URL structure

Brand pages have inconsistent trailing slashes in sitemap: some /products/brand/nordic-naturals/1/ and some /products/brand/nordic-naturals/1. The trailingSlash: "ignore" config causes this. See Phase 5.


Phase 4: Fix technical issues

4.1 Fix trailing slash consistency

File: apps/storev2/astro.config.mjs

Currently: trailingSlash: "ignore" — Astro doesn't enforce trailing slashes, causing duplicate URLs.

Change to: trailingSlash: "always" — all URLs end with /. This fixes the /products vs /products/ duplicate canonical issue from GSC.

Verify: All internal links already use trailing slashes (check categoryHref, baseUrl, breadcrumb links). Run astro build and check for errors.

4.2 Fix sitemap lastmod

File: apps/storev2/astro.config.mjs

Currently: lastmod: new Date() — all URLs get the same timestamp.

Change: Remove the static lastmod from the sitemap config. Astro's sitemap integration can use per-page lastmod if provided via serialize callback:

ts
1sitemap({
2 filter: (page) => /* existing filter */,
3 changefreq: "weekly",
4 priority: 0.7,
5 serialize(item) {
6 // Don't set lastmod for all pages — let it be omitted
7 // or set per-route based on the path
8 if (item.url.includes("/products/") && !item.url.includes("/category/") && !item.url.includes("/brand/")) {
9 // Product pages — could set based on product updatedAt
10 // For now, just remove the uniform lastmod
11 item.lastmod = undefined;
12 }
13 return item;
14 },
15}),

Alternative simpler fix: Just remove lastmod: new Date() from the config. The sitemap will omit lastmod entirely, which is better than all-identical timestamps.

4.3 Add geo-targeting meta tags

File: apps/storev2/src/components/SEO.astro

Add to the extend.meta array:

ts
1{ name: "geo.region", content: "MN" },
2{ name: "geo.placename", content: "Mongolia" },
3{ name: "language", content: "Mongolian" },
4{ name: "distribution", content: "Mongolia" },

4.4 Add hreflang for Mongolian

File: apps/storev2/src/components/SEO.astro

Add to extend.link:

ts
1{ rel: "alternate", hreflang: "mn-MN", href: canonicalURL },
2{ rel: "alternate", hreflang: "x-default", href: canonicalURL },

This tells Google the site is Mongolian-language, reducing irrelevant US impressions.

4.5 Add canonical URL with trailing slash to SEO component

File: apps/storev2/src/components/SEO.astro

Currently: const canonicalURL = canonical || new URL(Astro.url.pathname, SITE_URL).toString();

This may produce URLs without trailing slash. After changing trailingSlash: "always", verify canonical URLs end with /. If not, add:

ts
1let canonicalURL = canonical || new URL(Astro.url.pathname, SITE_URL).toString();
2if (!canonicalURL.endsWith("/")) canonicalURL += "/";

Phase 5: Add blog infrastructure (for content SEO)

5.1 Create blog content collection

File: apps/storev2/src/content/blog/ (new directory) File: apps/storev2/src/content.config.ts (new file)

ts
1import { glob } from "astro/loaders";
2import { defineCollection, z } from "astro:content";
3
4const blog = defineCollection({
5 loader: glob({ pattern: "**/*.{md,mdx}", base: "./src/content/blog" }),
6 schema: z.object({
7 title: z.string(),
8 description: z.string(),
9 pubDate: z.coerce.date(),
10 updatedDate: z.coerce.date().optional(),
11 category: z.string().optional(),
12 tags: z.array(z.string()).default([]),
13 image: z.string().optional(),
14 }),
15});
16
17export const collections = { blog };

5.2 Create blog page routes

File: apps/storev2/src/pages/blog/index.astro (new)

  • List all blog posts with titles, dates, excerpts
  • SEO: title "Витамин, эрүүл мэндийн мэдээлэл | Америк Витамин", description about health articles

File: apps/storev2/src/pages/blog/[slug].astro (new)

  • Render individual blog post from content collection
  • SEO: Article schema with publishedTime, modifiedTime, author = "Америк Витамин"
  • Add article prop to <Layout> / <SEO> component

5.3 Add blog to sitemap and navigation

File: apps/storev2/astro.config.mjs — remove /blog from sitemap filter if it's being filtered (it shouldn't be, but verify)

File: apps/storev2/src/layouts/Header.astro — add "Блог" link in navigation

File: apps/storev2/src/layouts/Footer.astro

Add a "Блог" or "Эрүүл мэндийн мэдээлэл" link in the footer services section.


Phase 6: Product page FAQ schema (per-product)

6.1 Add FAQ section to product pages

File: apps/storev2/src/components/product/product-details.astro

Add a 5th tab "Асуулт" (FAQ) with 3-5 generic product questions in Mongolian:

  • "Энэ бүтээгдэхүүнийг хэрхэн хэрэглэх вэ?" (How to use?)
  • "Хэзээ уух вэ?" (When to take?)
  • "Ямар гаж нөлөө байж болох вэ?" (Side effects?)
  • "Жирэмсэн үед хэрэглэж болох уу?" (Safe during pregnancy?)
  • "Хаанаас захиалах вэ?" (Where to order?)

These can be generated from product data (dailyIntake, category, etc.) or use category-specific templates.

6.2 Add FAQPage schema for product pages

File: apps/storev2/src/pages/products/[slug].astro

Add a faqSchema with the same Q&A pairs:

ts
1const faqSchema = {
2 "@context": "https://schema.org",
3 "@type": "FAQPage",
4 mainEntity: [
5 { "@type": "Question", name: "...", acceptedAnswer: { "@type": "Answer", text: "..." } },
6 // ...
7 ],
8};

Render with <script type="application/ld+json" set:html={JSON.stringify(faqSchema)} />.


Phase 7: Internal linking improvements

File: apps/storev2/src/pages/products/[slug].astro

Below the recommended products section, add a text-based "Төстэй хайлтууд" (Related searches) section with links to category and brand pages:

html
1<div class="border-t-4 border-border pt-8 pb-8">
2 <h2 class="text-xl font-black mb-4 uppercase">Төстэй ангилал</h2>
3 <div class="flex flex-wrap gap-2">
4 <a href="/products/category/vitamin-d/1/" class="border-2 border-border px-3 py-1.5 text-sm font-bold hover:bg-primary">Витамин D</a>
5 <a href="/products/brand/micro-ingredients/1/" class="border-2 border-border px-3 py-1.5 text-sm font-bold hover:bg-primary">Micro Ingredients</a>
6 </div>
7</div>

This creates internal links with keyword-rich anchor text.

7.2 Add keyword-rich text to products index page

File: apps/storev2/src/pages/products/index.astro

Add an intro section at the bottom of the page with Mongolian text about vitamin categories, brands, and usage. This adds crawlable keyword content to the main products page (which gets 284 impressions but only 2.8% CTR).


Phase 8: Performance & crawlability

8.1 Add Cache-Control headers for static assets

File: apps/storev2/src/middleware.ts or Cloudflare config

Ensure CSS/JS/images have long Cache-Control: public, max-age=31536000, immutable headers.

8.2 Preload critical resources

File: apps/storev2/src/layouts/Layout.astro

Add <link rel="preload"> for the primary font and hero image.

8.3 Remove /benchmark and /test from production

File: apps/storev2/src/pages/benchmark.astro and test.astro

These pages are already filtered from sitemap but still accessible. Add noindex meta or remove from production builds.


Implementation priority

PriorityPhaseEffortImpact
P01.1 — Use seoTitle/name_mn in product pagesSmallHigh — immediately improves titles for all products
P01.2 — Shorten product slugsMediumHigh — fixes URL truncation, improves CTR
P04.1 — Fix trailingSlash to "always"SmallHigh — fixes duplicate canonical
P12.1 — Fix category slugs to EnglishMediumHigh — makes category URLs searchable
P12.2 — Populate category SEO fieldsMediumHigh — category pages can rank for keywords
P13.1 — Populate brand SEO fieldsMediumMedium — brand pages already get some traffic
P14.4 — Add hreflang + geo tagsSmallMedium — reduces irrelevant US impressions
P14.2 — Fix sitemap lastmodSmallLow-Medium
P25.1-5.4 — Blog infrastructureMediumHigh (long-term) — captures top-of-funnel queries
P26.1-6.2 — Product FAQ schemaMediumMedium — FAQ rich results in SERP
P27.1-7.2 — Internal linkingSmallMedium
P38.1-8.3 — Performance tweaksSmallLow
P32.3 — CollectionPage schemaSmallLow
P33.2 — Brand schemaSmallLow

Key files to modify

FileChanges
apps/storev2/src/pages/products/[slug].astroUse seoTitle/seoDescription/name_mn, add FAQ schema, add related searches section
apps/storev2/src/components/product/product-info.astroShow name_mn as h1, English name as subtitle
apps/storev2/src/components/product/product-details.astroAdd Mongolian description section, add FAQ tab
apps/storev2/src/components/SEO.astroAdd geo tags, hreflang, trailing slash canonical fix
apps/storev2/astro.config.mjstrailingSlash: "always", fix sitemap lastmod
apps/storev2/src/pages/products/category/[slug]/[page].astroAdd CollectionPage schema, old-slug redirect
apps/storev2/src/pages/products/brand/[slug]/[page].astroAdd Brand schema
apps/storev2/src/pages/products/index.astroAdd keyword-rich intro text
apps/storev2/src/pages/blog/index.astroNew — blog listing page
apps/storev2/src/pages/blog/[slug].astroNew — blog post page
apps/storev2/src/content.config.tsNew — blog content collection
apps/storev2/src/layouts/Header.astroAdd blog nav link
apps/storev2/src/layouts/Footer.astroAdd blog footer link
packages/api/src/db/schema.tsAdd oldSlugs to CategoriesTable
packages/api/src/queries/products/store.tsEnsure seoTitle/seoDescription/name_mn in getProductById
packages/api/src/routers/admin/product.tsShorten slug generation logic
apps/server/src/scripts/regenerate-slugs.tsNew — one-time slug migration
apps/server/src/scripts/populate-category-seo.tsNew — populate category SEO fields
apps/server/src/scripts/populate-brand-seo.tsNew — populate brand SEO fields

Verification

After each phase:

  1. Run astro check in apps/storev2/ — no type errors
  2. Run astro build — build succeeds
  3. Deploy to dev/staging and verify:
    • Product page <title> shows Mongolian name (if name_mn set)
    • Product URL is under 80 chars
    • /products redirects to /products/
    • Sitemap URLs all have trailing slashes
    • Category pages show English slugs
    • Blog page renders at /blog/
    • View page source — JSON-LD schemas are valid (test with Google Rich Results Test)
  4. Submit updated sitemap to Google Search Console
  5. Monitor GSC for 1-2 weeks — expect increase in impressions for product/category keywords