Skip to content

Blog

The blog uses Astro’s content collections - Markdown files in src/content/blog/, typed with a Zod schema.


import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';
const blog = defineCollection({
loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/blog' }),
schema: z.object({
title: z.string(),
description: z.string(),
pubDate: z.coerce.date(),
updatedDate: z.union([z.coerce.date(), z.literal(''), z.null()]).optional(),
heroImage: z.string().optional(),
tags: z.array(z.string()).default([]),
author: z.string().default('Juju Alpha'),
draft: z.boolean().default(false),
faqs: z.array(z.object({ q: z.string(), a: z.string() })).optional(),
}),
});
export const collections = { blog };

updatedDate uses z.union instead of z.coerce.date() alone. Sveltia CMS writes an empty string "" when the field is left blank, and z.coerce.date("") produces an invalid Date that crashes the build.

---
title: "Why local businesses need a website in 2025"
description: "Most small businesses still rely on word of mouth. Here's why that's leaving money on the table."
pubDate: 2025-06-01
updatedDate: ""
tags: ["local-business", "website", "seo"]
author: "Juju Alpha"
draft: false
faqs:
- q: How long does a website take to build?
a: Most projects go live within 10-14 days of receiving all content.
- q: Do I need a website if I'm already on JustDial?
a: Yes. JustDial controls your listing and can change ranking or pricing at any time. Your own website is the only digital asset you fully own.
---
Post content starts here...

The filename becomes the URL slug:

src/content/blog/why-local-business-needs-a-website.md
-> /blog/why-local-business-needs-a-website/

Use kebab-case, no spaces, no uppercase.


Blog index page (src/pages/blog/index.astro)

Section titled “Blog index page (src/pages/blog/index.astro)”

Always filter for draft: false and pubDate <= now — hides both drafts and scheduled future posts at build time:

---
import { getCollection } from 'astro:content';
const now = new Date();
const posts = (await getCollection('blog', ({ data }) => !data.draft && data.pubDate <= now)).sort(
(a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()
);
---

Blog post page (src/pages/blog/[slug].astro)

Section titled “Blog post page (src/pages/blog/[slug].astro)”
---
import { getCollection, render } from 'astro:content';
export async function getStaticPaths() {
const now = new Date();
const posts = await getCollection('blog', ({ data }) => !data.draft && data.pubDate <= now);
return posts.map(p => ({ params: { slug: p.id }, props: { post: p } }));
}
const { post } = Astro.props;
const { Content } = await render(post);
const faqs = post.data.faqs ?? [];
const faqSchema = faqs.length ? {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: faqs.map((f: { q: string; a: string }) => ({
'@type': 'Question',
name: f.q,
acceptedAnswer: { '@type': 'Answer', text: f.a },
})),
} : null;
---
<Layout title={post.data.title} description={post.data.description}>
<Fragment slot="head">
{faqSchema && <script is:inline type="application/ld+json" set:html={JSON.stringify(faqSchema)} />}
</Fragment>
<article class="prose prose-invert max-w-none">
<Content />
</article>
</Layout>

The faqs type annotation (f: { q: string; a: string }) is required in strict TypeScript mode — omitting it causes an implicit any error. Use const faqs = post.data.faqs ?? [] before checking .length, otherwise TypeScript may narrow to never.


Apply the same filter:

const now = new Date();
const posts = await getCollection('blog', ({ data }) => !data.draft && data.pubDate <= now);

Apply the same filter:

const now = new Date();
const posts = (await getCollection('blog', ({ data }) => !data.draft && data.pubDate <= now)).sort(...);

Static Astro sites only update on rebuild. To make future-dated posts go live automatically without manual deploys:

  1. Set pubDate to a future date in the CMS, leave draft: false
  2. The filter data.pubDate <= now hides it until a build runs on or after that date
  3. Add a daily GitHub Actions cron to trigger Cloudflare Pages rebuilds
name: Scheduled Build
on:
schedule:
- cron: "30 3 * * *" # 3:30 UTC = 9:00 IST daily
workflow_dispatch: # allows manual trigger from GitHub UI
jobs:
trigger-deploy:
runs-on: ubuntu-latest
steps:
- name: Trigger Cloudflare Pages Deploy
run: curl -s -X POST "${{ secrets.CF_PAGES_DEPLOY_HOOK }}"

Setup: Cloudflare Pages → project → Settings → Builds & deployments → Deploy hooks → create a hook → copy the URL → add it as CF_PAGES_DEPLOY_HOOK in the GitHub repo’s Secrets (Settings → Secrets and variables → Actions).


VS Code false-positive errors after changing content.config.ts

Section titled “VS Code false-positive errors after changing content.config.ts”

After modifying content.config.ts (adding fields, changing types), VS Code may show cascade errors in all files that call getCollection:

Property 'data' does not exist on type 'never'

These are stale .astro/types.d.ts cache errors from the Astro language server. npm run build is the source of truth. If it passes cleanly, the errors are false positives. Restart the TypeScript server (Ctrl+Shift+P → “TypeScript: Restart TS Server”) to clear them.


  • Each post gets its own title and description via frontmatter
  • The [slug].astro page passes these to <Layout>
  • Add faqs to frontmatter to get FAQPage JSON-LD and Google FAQ accordion rich results
  • Blog posts are included in the sitemap automatically via @astrojs/sitemap
  • After each post goes live: submit in Google Search Console → URL Inspection → Request Indexing

  • Write for the client’s target customer, not for other developers
  • Target one keyword per post (include in title, first paragraph, at least one H2)
  • 1,500-2,500 words is the sweet spot for service business blogs targeting India SMB queries
  • Include 4-5 faqs per post for FAQ rich results
  • Link internally to service pages where relevant
  • End every post with a CTA linking to /contact