Blog Guide — yas.sh
Add a blog in under 5 minutes. No framework magic, no code edits for basic posts.
1. Where Blogs Live
blog/content/
<slug>/
index.mdx ← required: frontmatter + markdown
hero.webp ← required: 1280×720 (used on post + listing)
cover.webp ← optional: 800×600 (fallback to hero)
thumb.webp ← optional: 400×300 (listing thumb, fallback to hero)
_stubs.json ← backlog of 45 stub ideas (not rendered)
- Adding a post = adding a folder. No registry file.
generateStaticParams()readsblog/contentdirectly. - Images are co-located; serving via
/blog-img/[slug]/[image]is handled by Next (copy topublic/blog-imgin prod viascripts/copy-blog-images.shor vianext.configrewrites). During dev,public/blog-imgsymlinked or copied.
2. Frontmatter (Metadata)
Every index.mdx starts with YAML frontmatter between --- lines.
---
title: "URL Shortening Explained: Architecture, Trade-offs, and Best Practices"
slug: understanding-url-shortening
description: "How modern URL shorteners generate codes, handle redirects, and scale to millions of links — with API examples."
excerpt: "A practical guide to short links that actually convert — from 301 vs 302 to branded domains."
author: yas-team
publishedAt: "2026-08-08"
updatedAt: "2026-08-08"
category: "URL Shortening"
tags: ["links", "analytics", "seo", "qr", "api"]
heroImage: "./hero.webp"
coverImage: "./cover.webp"
thumbImage: "./thumb.webp"
readingTime: 9
featured: true
related: ["qr-codes-for-business", "custom-aliases-seo", "link-analytics-explained"]
faq:
- q: "Do short links hurt SEO?"
a: "No. A 301 preserves link equity; 302 preserves attribution. Set canonical on destination."
- q: "What's the difference between 301 and 302?"
a: "301 is permanent (cached), 302 is temporary (counted each click)."
seo:
title: "URL Shortening Explained: Architecture, Trade-offs, and Best Practices | yas.sh"
description: "How shorteners generate codes, handle redirects, aliases, expiry, and analytics — with API examples."
keywords: ["url shortener", "short link", "custom alias", "link analytics"]
---
Field Reference
| Field | Required | Description |
|---|---|---|
title |
yes | 50–70 chars, frontmatter + <h1> + SEO |
slug |
yes | kebab-case, folder name, URL /blog/<slug> |
description |
yes | 140–160 chars for SEO + card excerpt |
excerpt |
yes | Short card text (fallback to description) |
author |
yes | yas-team or author id |
publishedAt |
yes | YYYY-MM-DD ISO date |
updatedAt |
yes | Bumped on edit |
category |
yes | Single category, displayed as Badge |
tags |
yes | Array up to 5, lowercase |
heroImage |
yes | ./hero.webp relative |
coverImage |
no | fallback to hero |
thumbImage |
no | fallback to hero |
readingTime |
yes | integer minutes (auto-calc ~200 wpm if omitted, defaults 7) |
featured |
no | boolean, pins to top on /blog |
related |
no | array of slugs → renders links |
faq |
no | array of {q,a} → FAQ section + JSON-LD |
seo.title |
no | defaults to `title |
seo.description |
no | defaults to description |
seo.keywords |
no | defaults to tags |
Validation: lib/blog not needed — generateStaticParams + generateMetadata read frontmatter directly. Zod validation at build would be added via blog/schema.ts in Phase 2.
3. How to Add a New Article (5 Minutes)
- Create folder
mkdir -p blog/content/my-new-post - Add
index.mdxcp blog/content/understanding-url-shortening/index.mdx blog/content/my-new-post/index.mdx # edit frontmatter + body - Add images
cp blog/content/understanding-url-shortening/hero.webp blog/content/my-new-post/hero.webp # replace with 1280×720 WebP. Use `npx sharp-cli` to convert. # Optional: cover.webp + thumb.webp (or omit — hero used) - Verify
pnpm dev # open http://localhost:3000/blog → your post appears, no restart needed (HMR) # open http://localhost:3000/blog/my-new-post → renders MDX, SEO, JSON-LD
No code edits. No re-build. Auto-listed via filesystem.
4. Images
- Format: WebP (sharp optimized). Keep hero <150kb, thumb <40kb.
- Co-location:
./hero.webpkeeps assets near content for easy diff/review. - Serving:
app/blog/[slug]/page.tsxuses<img src="/blog-img/<slug>/hero.webp">. In prod,public/blog-imgis populated viascripts/copy-blog-images.sh(run inpnpm buildpre-step) or via Next rewrite serving fromblog/content. - Fallback: If
cover.webpmissing,hero.webpis used; same forthumb.
Generate with sharp:
npx sharp -i input.png -o blog/content/my-post/hero.webp --webp '{"quality":80}'
5. Tags & Categories
- Category: Single, broad buckets (
URL Shortening,Analytics,Engineering,AI Infrastructure,QR, etc.). Rendered as badge and breadcrumb. - Tags: Up to 5 free-form. Displayed as pills on cards and used for SEO keywords. Synonyms folded client-side (e.g.,
seo+SEO→seo). - Filtering: Future
?tag=ai-infrawill be handled inapp/blog/page.tsx— currently lists all; filter isArray.filterontags.includes.
6. Related Posts
related: ["slug-1","slug-2"] renders as pill links under article. No auto-generation yet — manually curate 2–4 highly relevant. Missing slugs are skipped (no 404).
7. Featured Posts
featured: true pins to top if homepage/blog hero is implemented. Currently all posts sorted by publishedAt desc; featured tint is CSS (border-[var(--brand)]) handled in app/blog/page.tsx.
8. Markdown & Rendering
- Flavour: GFM via
marked(tables, fenced code, autolinks). - Safety:
app/blog/[slug]/page.tsxsanitizes HTML — strips<script>,on*handlers,javascript:URLs, and addsrel="noopener"to external links. Author Markdown is trusted but sanitized. - Code blocks: Fenced ```ts etc. rendered with
prose-pre:bg-[#0b0b10]and horizontal scroll. - Links: Internal
[text](/blog/other)stays SPA; external links gettarget="_blank"via sanitizer.
9. SEO & Structured Data
- Per-post metadata:
generateMetadata()emitstitle,description,canonical,openGraph,twitter. - JSON-LD: Article + FAQPage injected in
<script type="application/ld+json">. - Sitemap/Robots:
app/sitemap.tsshould enumerate blog slugs (add in Phase 2). Currently static sitemap handles pages; extend to include...slugs.map(s=> ({url: https://yas.sh/blog/${s}})). - Headings: Use
##for sections, single#is title via frontmatter. Hierarchy enforced via lint script.
10. Reading Time
readingTime is manual minutes. Estimate 200 wpm. E.g., 1800 words → 9 min. Future: auto-calc via words / 200 if omitted.
11. Troubleshooting
| Issue | Fix |
|---|---|
| Post not showing | Folder missing index.mdx or slug mismatch; check fs.readdirSync filter !f.startsWith("_") |
| Image 404 | Ensure hero.webp exists and public/blog-img populated; run bash scripts/copy-blog-images.sh |
| Invalid frontmatter | YAML must be valid; quotes needed if value contains : |
| 404 on slug | Slug must match /^[a-z0-9-]+$/ — no caps, no underscores |
12. Scripts
# Copy blog images to public for serving (prod)
bash scripts/copy-blog-images.sh
# Audit: list all posts with word count
node -e "const fs=require('fs');fs.readdirSync('blog/content').filter(f=>!f.startsWith('_')).forEach(s=>{const c=fs.readFileSync('blog/content/'+s+'/index.mdx','utf8');console.log(s, (c.split(/\s+/).length+' words'))})"
Owner: Team — add via folder; no approval for draft; publish is merge to main triggers redeploy.