SEO overhaul: metadata, robots, sitemap, structured data

- Add metadataBase to root layout so OG/Twitter/canonical URLs resolve
  to absolute https URLs (fixes broken social previews)
- New src/lib/seo.ts with SITE_URL + JSON-LD builders
- New robots.ts (disallow api/admin/private app paths) and sitemap.ts
  (marketing pages + blog posts with real lastmod dates)
- JSON-LD: Organization/WebSite/SoftwareApplication on home,
  Blog+Breadcrumb on blog list, BlogPosting+Breadcrumb on posts
- Per-page canonical + Open Graph on all marketing pages; article OG
  + Twitter cards on blog posts; per-post dynamic OG image
- noindex on (app) and admin layouts; richer PWA manifest
- Fix CSP to allow plausible.io in script-src/connect-src (analytics
  was silently blocked)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Manohar Gupta 2026-05-29 11:03:04 +05:30
parent 39b2787484
commit dad0611350
18 changed files with 500 additions and 15 deletions

View file

@ -23,9 +23,9 @@ const nextConfig: NextConfig = {
{ key: "Content-Security-Policy", value:
"default-src 'self'; " +
"img-src 'self' data: https://*.r2.cloudflarestorage.com https://*.r2.dev; " +
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; " +
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://plausible.io; " +
"style-src 'self' 'unsafe-inline'; " +
"connect-src 'self' https://llm.manohargupta.com; " +
"connect-src 'self' https://llm.manohargupta.com https://plausible.io; " +
"font-src 'self' data:;"
},
],

File diff suppressed because one or more lines are too long

View file

@ -1,9 +1,15 @@
import type { Metadata } from "next";
import { ThemeProvider } from "@/app/ThemeProvider";
import { FamilyProvider } from "@/app/FamilyProvider";
import { PageTransition } from "@/components/PageTransition";
import { BottomNav } from "@/components/BottomNav";
import { InstallPrompt } from "@/components/InstallPrompt";
// Private, authenticated app — never index these pages.
export const metadata: Metadata = {
robots: { index: false, follow: false },
};
export default function AppLayout({
children,
}: {

View file

@ -2,8 +2,15 @@ import type { Metadata } from "next";
import Link from "next/link";
export const metadata: Metadata = {
title: "About",
title: "About Tia",
description: "The story behind Tia — built for Indian families by a parent who wanted something better.",
alternates: { canonical: "/about" },
openGraph: {
type: "website",
url: "/about",
title: "About Tia",
description: "The story behind Tia — built for Indian families by a parent who wanted something better.",
},
};
export default function AboutPage() {

View file

@ -0,0 +1,82 @@
import { ImageResponse } from "next/og";
import { getPost, POSTS } from "../posts";
export const alt = "Tia Blog";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
// Pre-render an OG image for every known post.
export function generateStaticParams() {
return POSTS.map((p) => ({ slug: p.slug }));
}
export default async function BlogOgImage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = getPost(slug);
const title = post?.title ?? "The Tia Blog";
const category = post?.category ?? "Journal";
const emoji = post?.emoji ?? "🌸";
return new ImageResponse(
(
<div
style={{
background:
"linear-gradient(135deg, #fdf2f2 0%, #fef3c7 50%, #fdf2f2 100%)",
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
fontFamily: "sans-serif",
padding: 80,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
<span style={{ fontSize: 40 }}>🌸</span>
<span style={{ fontSize: 34, fontWeight: 800, color: "#111827" }}>
Tia
</span>
<span
style={{
marginLeft: 12,
fontSize: 22,
fontWeight: 600,
color: "#f43f5e",
background: "#ffe4e6",
padding: "6px 18px",
borderRadius: 999,
}}
>
{category}
</span>
</div>
<div style={{ display: "flex", flexDirection: "column" }}>
<div style={{ fontSize: 64, marginBottom: 16 }}>{emoji}</div>
<div
style={{
fontSize: 56,
fontWeight: 800,
color: "#111827",
lineHeight: 1.15,
maxWidth: 1000,
}}
>
{title}
</div>
</div>
<div style={{ fontSize: 24, color: "#6b7280" }}>
tia.manohargupta.com/blog
</div>
</div>
),
{ ...size }
);
}

View file

@ -3,6 +3,11 @@ import Link from "next/link";
import { notFound } from "next/navigation";
import { POSTS, getPost, formatDate } from "../posts";
import { Breadcrumb } from "@/components/marketing/Breadcrumb";
import {
blogPostingSchema,
breadcrumbSchema,
jsonLdScript,
} from "@/lib/seo";
// Turn a heading string into a URL-safe anchor ID
function slugifyHeading(heading: string): string {
@ -26,9 +31,29 @@ export async function generateMetadata({
const { slug } = await params;
const post = getPost(slug);
if (!post) return {};
const url = `/blog/${post.slug}`;
return {
title: post.title,
description: post.excerpt,
alternates: { canonical: url },
openGraph: {
type: "article",
url,
siteName: "Tia",
locale: "en_IN",
title: post.title,
description: post.excerpt,
publishedTime: post.date,
modifiedTime: post.date,
authors: [post.author],
section: post.category,
// Uses the per-post opengraph-image.tsx in this segment.
},
twitter: {
card: "summary_large_image",
title: post.title,
description: post.excerpt,
},
};
}
@ -52,6 +77,18 @@ export default async function BlogPostPage({
return (
<div className="min-h-screen bg-white">
{/* Structured data — BlogPosting + breadcrumb trail */}
<script
type="application/ld+json"
dangerouslySetInnerHTML={jsonLdScript([
blogPostingSchema(post),
breadcrumbSchema([
{ name: "Home", path: "/" },
{ name: "Blog", path: "/blog" },
{ name: post.title, path: `/blog/${post.slug}` },
]),
])}
/>
{/* Post hero / header */}
<div className="bg-gradient-to-br from-rose-50 to-pink-50 border-b border-rose-100">
<div className="max-w-6xl mx-auto px-5 pt-10 pb-10">

View file

@ -2,13 +2,46 @@ import type { Metadata } from "next";
import Link from "next/link";
import { POSTS, formatDate } from "./posts";
import { Breadcrumb } from "@/components/marketing/Breadcrumb";
import {
SITE_NAME,
absoluteUrl,
blogPostingSchema,
breadcrumbSchema,
jsonLdScript,
} from "@/lib/seo";
export const metadata: Metadata = {
title: "Blog",
title: "Blog — Baby Feeding, Health & Vaccination Guides",
description:
"Guides on baby feeding, health milestones, vaccination schedules, and how to make the most of Tia — written for Indian families.",
alternates: { canonical: "/blog" },
openGraph: {
type: "website",
url: "/blog",
siteName: SITE_NAME,
locale: "en_IN",
title: `Blog | ${SITE_NAME}`,
description:
"Guides on baby feeding, health milestones, vaccination schedules, and how to make the most of Tia — written for Indian families.",
},
};
// Blog (with each post) + breadcrumb structured data.
const blogSchema = {
"@context": "https://schema.org",
"@type": "Blog",
"@id": `${absoluteUrl("/blog")}#blog`,
name: `${SITE_NAME} Blog`,
url: absoluteUrl("/blog"),
inLanguage: "en-IN",
blogPost: POSTS.map((p) => blogPostingSchema(p)),
};
const blogBreadcrumb = breadcrumbSchema([
{ name: "Home", path: "/" },
{ name: "Blog", path: "/blog" },
]);
// Derive unique categories + counts from posts
const CATEGORIES = Object.values(
POSTS.reduce<Record<string, { name: string; color: string; count: number }>>((acc, p) => {
@ -23,6 +56,10 @@ const CATEGORIES = Object.values(
export default function BlogPage() {
return (
<div className="min-h-screen bg-white">
<script
type="application/ld+json"
dangerouslySetInnerHTML={jsonLdScript([blogSchema, blogBreadcrumb])}
/>
{/* Page header */}
<div className="bg-gradient-to-br from-rose-50 to-pink-50 border-b border-rose-100">
<div className="max-w-6xl mx-auto px-5 py-12 text-center">

View file

@ -1,11 +1,38 @@
import type { Metadata } from "next";
import Link from "next/link";
import { PhoneMockup } from "@/components/marketing/PhoneMockup";
import {
jsonLdScript,
organizationSchema,
websiteSchema,
softwareApplicationSchema,
} from "@/lib/seo";
export const metadata: Metadata = {
title: "Tia — Your baby's digital heirloom",
title: "Tia — Baby Tracker & Digital Heirloom for Indian Families",
description:
"Tia is a digital heirloom for your baby — not just a tracker. Log daily moments, track the IAP vaccination schedule with Telegram alerts, and build a living archive your child will one day inherit.",
"Tia is a digital heirloom for your baby — not just a tracker. Log daily moments, track the IAP vaccination schedule with Telegram alerts, and build a living archive your child will one day inherit. Free during early access.",
keywords: [
"baby tracker",
"baby tracker app India",
"IAP vaccination schedule",
"baby vaccination tracker",
"newborn feed tracker",
"baby milestone tracker",
"digital baby book",
"parenting app India",
"baby memory keeper",
],
alternates: { canonical: "/" },
openGraph: {
type: "website",
url: "/",
siteName: "Tia",
locale: "en_IN",
title: "Tia — Baby Tracker & Digital Heirloom for Indian Families",
description:
"Log every feed, milestone, and memory. Track the IAP vaccination schedule with Telegram alerts. Built for Indian families. Privacy-first. Free during early access.",
},
};
// ── Google G icon (reusable) ────────────────────────────────────
@ -373,6 +400,15 @@ function FinalCTA() {
export default function MarketingHomePage() {
return (
<>
{/* Structured data — Organization, WebSite, and SoftwareApplication */}
<script
type="application/ld+json"
dangerouslySetInnerHTML={jsonLdScript([
organizationSchema(),
websiteSchema(),
softwareApplicationSchema(),
])}
/>
<Hero />
<TheProblem />
<Features />

View file

@ -4,6 +4,13 @@ import Link from "next/link";
export const metadata: Metadata = {
title: "Partners",
description: "Clinics, pediatricians, and organisations partnering with Tia to support Indian families.",
alternates: { canonical: "/partners" },
openGraph: {
type: "website",
url: "/partners",
title: "Partners | Tia",
description: "Clinics, pediatricians, and organisations partnering with Tia to support Indian families.",
},
};
export default function PartnersPage() {

View file

@ -3,7 +3,14 @@ import Link from "next/link";
export const metadata: Metadata = {
title: "Pricing",
description: "Founder pricing — early families keep their terms for life.",
description: "Founder pricing — early families keep their terms for life. Free during early access.",
alternates: { canonical: "/pricing" },
openGraph: {
type: "website",
url: "/pricing",
title: "Pricing | Tia",
description: "Founder pricing — early families keep their terms for life. Free during early access.",
},
};
export default function PricingPage() {

View file

@ -3,6 +3,13 @@ import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Privacy Policy",
description: "How Tia handles your family's data. We don't sell it — we preserve it.",
alternates: { canonical: "/privacy" },
openGraph: {
type: "website",
url: "/privacy",
title: "Privacy Policy | Tia",
description: "How Tia handles your family's data. We don't sell it — we preserve it.",
},
};
export default function PrivacyPage() {

View file

@ -2,7 +2,14 @@ import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Terms of Service",
description: "Terms of service for Tia.",
description: "Terms of service for Tia — the baby tracker and digital heirloom for Indian families.",
alternates: { canonical: "/terms" },
openGraph: {
type: "website",
url: "/terms",
title: "Terms of Service | Tia",
description: "Terms of service for Tia — the baby tracker and digital heirloom for Indian families.",
},
};
export default function TermsPage() {

View file

@ -1,7 +1,13 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { verifyAdminSession } from "@/lib/admin-auth";
import AdminSidebar from "./AdminSidebar";
// Admin panel — never index.
export const metadata: Metadata = {
robots: { index: false, follow: false },
};
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
const auth = await verifyAdminSession();
if (!auth.success) {

View file

@ -1,5 +1,6 @@
import type { Metadata, Viewport } from "next";
import { Geist, Geist_Mono, Caveat } from "next/font/google";
import { SITE_URL, SITE_NAME } from "@/lib/seo";
import "./globals.css";
const geistSans = Geist({
@ -18,17 +19,41 @@ const caveat = Caveat({
});
export const metadata: Metadata = {
title: "Tia — Baby Tracker",
description: "Track feeds, sleep, milestones and memories.",
// Resolves all relative metadata URLs (OG/Twitter images, canonicals) to
// absolute URLs — required for social previews to work.
metadataBase: new URL(SITE_URL),
title: {
default: "Tia — Baby Tracker & Digital Heirloom",
template: `%s | ${SITE_NAME}`,
},
description:
"Track feeds, sleep, milestones, and memories. Tia is a privacy-first baby tracker and digital heirloom built for Indian families.",
applicationName: SITE_NAME,
authors: [{ name: "Tia" }],
creator: "Tia",
publisher: "Tia",
category: "parenting",
formatDetection: { telephone: false, email: false, address: false },
// apple-touch-icon is provided by the src/app/apple-icon.png file convention.
icons: {
icon: "/icon.svg",
apple: "/apple-icon.png",
},
appleWebApp: {
capable: true,
statusBarStyle: "default",
title: "Tia",
},
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
"max-image-preview": "large",
"max-snippet": -1,
"max-video-preview": -1,
},
},
};
export const viewport: Viewport = {
@ -41,7 +66,7 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en" suppressHydrationWarning>
<html lang="en-IN" suppressHydrationWarning>
<body className={`${geistSans.variable} ${geistMono.variable} ${caveat.variable} min-h-full antialiased`}>
{children}
</body>

View file

@ -2,11 +2,18 @@ import type { MetadataRoute } from "next";
export default function manifest(): MetadataRoute.Manifest {
return {
name: "Tia — Baby Tracker",
name: "Tia — Baby Tracker & Digital Heirloom",
short_name: "Tia",
description: "Track feeds, sleep, milestones and memories.",
description:
"Track feeds, sleep, milestones, and memories. Tia is a privacy-first baby tracker and digital heirloom built for Indian families — with the IAP vaccination schedule and Telegram alerts.",
id: "/home",
start_url: "/home?source=pwa",
scope: "/",
display: "standalone",
orientation: "portrait",
lang: "en-IN",
dir: "ltr",
categories: ["health", "lifestyle", "parenting", "medical"],
background_color: "#fdf2f2",
theme_color: "#fb7185",
icons: [

41
src/app/robots.ts Normal file
View file

@ -0,0 +1,41 @@
import type { MetadataRoute } from "next";
import { SITE_URL } from "@/lib/seo";
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: "*",
allow: "/",
// Private app, auth, and admin areas — keep them out of the index.
disallow: [
"/api/",
"/admin",
"/admin-login",
"/login",
"/verify",
"/invite/",
"/onboarding",
"/home",
"/activity",
"/ai",
"/circle",
"/family",
"/growth",
"/medical",
"/memories",
"/menu",
"/milestones",
"/notifications",
"/profile",
"/settings",
"/wardrobe",
"/dev",
"/m/",
],
},
],
sitemap: `${SITE_URL}/sitemap.xml`,
host: SITE_URL,
};
}

38
src/app/sitemap.ts Normal file
View file

@ -0,0 +1,38 @@
import type { MetadataRoute } from "next";
import { SITE_URL } from "@/lib/seo";
import { POSTS } from "@/app/(marketing)/blog/posts";
export default function sitemap(): MetadataRoute.Sitemap {
const now = new Date();
// Static marketing routes with curated priorities.
const staticRoutes: {
path: string;
changeFrequency: MetadataRoute.Sitemap[number]["changeFrequency"];
priority: number;
}[] = [
{ path: "/", changeFrequency: "weekly", priority: 1.0 },
{ path: "/pricing", changeFrequency: "monthly", priority: 0.8 },
{ path: "/about", changeFrequency: "monthly", priority: 0.7 },
{ path: "/blog", changeFrequency: "weekly", priority: 0.7 },
{ path: "/partners", changeFrequency: "monthly", priority: 0.5 },
{ path: "/privacy", changeFrequency: "yearly", priority: 0.3 },
{ path: "/terms", changeFrequency: "yearly", priority: 0.3 },
];
const staticEntries: MetadataRoute.Sitemap = staticRoutes.map((r) => ({
url: `${SITE_URL}${r.path}`,
lastModified: now,
changeFrequency: r.changeFrequency,
priority: r.priority,
}));
const blogEntries: MetadataRoute.Sitemap = POSTS.map((post) => ({
url: `${SITE_URL}/blog/${post.slug}`,
lastModified: new Date(post.date),
changeFrequency: "monthly",
priority: 0.6,
}));
return [...staticEntries, ...blogEntries];
}

135
src/lib/seo.ts Normal file
View file

@ -0,0 +1,135 @@
// Central SEO constants and JSON-LD structured-data builders.
// Used across the marketing site so titles, canonicals, Open Graph,
// and schema.org markup all stay consistent.
/** Canonical production origin. Falls back to the known prod URL. */
export const SITE_URL = (
process.env.NEXT_PUBLIC_APP_URL || "https://tia.manohargupta.com"
).replace(/\/$/, "");
export const SITE_NAME = "Tia";
export const SITE_TAGLINE = "Your baby's digital heirloom";
export const SITE_DESCRIPTION =
"Tia is a digital heirloom for your baby — not just a tracker. Log every feed, milestone, and memory, track the IAP vaccination schedule with Telegram alerts, and build a living, private archive your child will one day inherit. Built for Indian families.";
/** Absolute URL helper — keeps canonical/OG urls consistent. */
export function absoluteUrl(path = "/"): string {
if (path.startsWith("http")) return path;
return `${SITE_URL}${path.startsWith("/") ? path : `/${path}`}`;
}
// ── JSON-LD builders ─────────────────────────────────────────────
/** Organization — publisher identity, reused as the publisher of articles. */
export function organizationSchema() {
return {
"@context": "https://schema.org",
"@type": "Organization",
"@id": `${SITE_URL}/#organization`,
name: SITE_NAME,
url: SITE_URL,
logo: {
"@type": "ImageObject",
url: absoluteUrl("/icons/512.png"),
width: 512,
height: 512,
},
description: SITE_DESCRIPTION,
foundingDate: "2026",
areaServed: "IN",
email: "hello@tia.baby",
contactPoint: {
"@type": "ContactPoint",
telephone: "+91-95548-81799",
contactType: "customer support",
email: "hello@tia.baby",
areaServed: "IN",
availableLanguage: ["English", "Hindi"],
},
};
}
/** WebSite — enables sitelinks/search treatment. */
export function websiteSchema() {
return {
"@context": "https://schema.org",
"@type": "WebSite",
"@id": `${SITE_URL}/#website`,
name: SITE_NAME,
url: SITE_URL,
description: SITE_DESCRIPTION,
inLanguage: "en-IN",
publisher: { "@id": `${SITE_URL}/#organization` },
};
}
/** SoftwareApplication — tells search engines this is an app, free to use. */
export function softwareApplicationSchema() {
return {
"@context": "https://schema.org",
"@type": "SoftwareApplication",
name: `${SITE_NAME} — Baby Tracker & Digital Heirloom`,
operatingSystem: "Web, iOS, Android",
applicationCategory: "HealthApplication",
description: SITE_DESCRIPTION,
url: SITE_URL,
image: absoluteUrl("/icons/512.png"),
inLanguage: "en-IN",
offers: {
"@type": "Offer",
price: "0",
priceCurrency: "INR",
availability: "https://schema.org/InStock",
},
publisher: { "@id": `${SITE_URL}/#organization` },
};
}
/** BreadcrumbList from an ordered list of { name, path }. */
export function breadcrumbSchema(items: { name: string; path: string }[]) {
return {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: items.map((item, i) => ({
"@type": "ListItem",
position: i + 1,
name: item.name,
item: absoluteUrl(item.path),
})),
};
}
/** BlogPosting / Article schema for a single post. */
export function blogPostingSchema(post: {
slug: string;
title: string;
excerpt: string;
date: string;
author: string;
}) {
const url = absoluteUrl(`/blog/${post.slug}`);
return {
"@context": "https://schema.org",
"@type": "BlogPosting",
"@id": `${url}#article`,
headline: post.title,
description: post.excerpt,
datePublished: post.date,
dateModified: post.date,
inLanguage: "en-IN",
image: absoluteUrl(`/blog/${post.slug}/opengraph-image`),
author: { "@type": "Organization", name: post.author, url: SITE_URL },
publisher: { "@id": `${SITE_URL}/#organization` },
mainEntityOfPage: { "@type": "WebPage", "@id": url },
};
}
/**
* Builds the `dangerouslySetInnerHTML` payload for a JSON-LD <script>.
* Pass one schema object or an array. Set `type="application/ld+json"` on
* the <script> element itself.
*/
export function jsonLdScript(data: object | object[]): { __html: string } {
// JSON.stringify is XSS-safe here because all inputs are our own constants.
return { __html: JSON.stringify(data) };
}