feat(seo): full SEO pass — metadata, structured data, sitemap, robots

Brings the site from bare-bones metadata to production-ready SEO.
Covers technical crawlability, rich-result eligibility, social
sharing, and duplicate-content prevention.

Added:
- src/lib/seo/site-config.ts — single source of truth for base URL,
  brand, description, keywords. Referenced by every consumer so a
  domain / rename is a one-file edit.
- src/lib/seo/faq-data.ts — FAQ Q&A extracted from the component so
  the UI and the FAQPage JSON-LD render the same content.
- src/lib/seo/structured-data.ts — builders for WebSite,
  Organization, SoftwareApplication, and FAQPage JSON-LD nodes.
  FAQPage is eligible for Google's FAQ rich result; SoftwareApplication
  with a free Offer can earn a "Free" badge on SERP.
- src/components/seo/json-ld.tsx — emits one <script
  type="application/ld+json"> per node (Google's preferred shape).
- src/app/opengraph-image.tsx — Next auto-generates a 1200×630 PNG
  for the landing page's OG / Twitter previews.
- src/app/sitemap.ts — lists only public, indexable pages.
- src/app/robots.ts — Allow /, Disallow every app / auth route,
  references the sitemap.

Updated:
- src/app/layout.tsx — metadataBase, title template (%s — WaCRM),
  default OpenGraph + Twitter Cards (inherited everywhere), canonical,
  icons config, viewport (themeColor + colorScheme=dark for browser
  chrome on mobile).
- src/app/page.tsx — injects the 4 JSON-LD blocks, sets explicit
  canonical for /, overrides the title.
- src/app/(auth)/layout.tsx — noindex+nofollow for login / signup /
  forgot-password (no SEO value; would compete with landing page).
- src/app/(dashboard)/layout.tsx — split into thin server layout
  (exports noindex metadata) + DashboardShell client component. App
  routes won't be indexed even if a URL leaks.
- src/components/landing/faq.tsx — consumes the shared FAQ data
  module (no more drift between UI and JSON-LD).

Verified end-to-end against the running dev server:
- GET / HTML contains: canonical, description, keywords, robots,
  og:title/url/image/type, twitter:card/title, theme-color, + 4
  JSON-LD blocks (WebSite, Organization, SoftwareApplication,
  FAQPage).
- GET /opengraph-image → 200 OK, content-type image/png.
- GET /login HTML contains <meta name="robots" content="noindex,
  nofollow, nocache">.
- GET /sitemap.xml serves valid XML with /.
- GET /robots.txt serves Allow /, Disallow all app + auth paths,
  sitemap reference.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arnas Donauskas 2026-04-19 11:17:48 +03:00
parent ba08d0b45f
commit c2587f7466
No known key found for this signature in database
13 changed files with 573 additions and 82 deletions

24
src/app/(auth)/layout.tsx Normal file
View File

@ -0,0 +1,24 @@
import type { Metadata } from "next";
import type { ReactNode } from "react";
// Shared metadata for auth pages (login / signup / forgot-password).
// None of these should be indexed — they'd compete with the marketing
// landing in SERPs and offer nothing to a searcher who hasn't already
// signed up. Each page still gets its own <title> via its own
// metadata.title override below the route group layout.
export const metadata: Metadata = {
robots: {
index: false,
follow: false,
nocache: true,
googleBot: {
index: false,
follow: false,
noimageindex: true,
},
},
};
export default function AuthLayout({ children }: { children: ReactNode }) {
return children;
}

View File

@ -0,0 +1,53 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { AuthProvider, useAuth } from "@/hooks/use-auth";
import { Sidebar } from "@/components/layout/sidebar";
import { Header } from "@/components/layout/header";
// Auth-gated dashboard shell. Extracted from the layout so the layout
// itself can stay a server component and export metadata (noindex) —
// client components can't export Next's metadata object.
function DashboardShellInner({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth();
const router = useRouter();
useEffect(() => {
if (!loading && !user) {
router.push("/login");
}
}, [user, loading, router]);
if (loading) {
return (
<div className="flex h-screen items-center justify-center bg-slate-950">
<div className="flex flex-col items-center gap-3">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-emerald-500 border-t-transparent" />
<p className="text-sm text-slate-400">Loading...</p>
</div>
</div>
);
}
if (!user) return null;
return (
<div className="flex h-screen overflow-hidden bg-slate-950">
<Sidebar />
<div className="flex flex-1 flex-col overflow-hidden">
<Header />
<main className="flex-1 overflow-y-auto p-6">{children}</main>
</div>
</div>
);
}
export function DashboardShell({ children }: { children: React.ReactNode }) {
return (
<AuthProvider>
<DashboardShellInner>{children}</DashboardShellInner>
</AuthProvider>
);
}

View File

@ -1,53 +1,28 @@
"use client";
import type { Metadata } from "next";
import { DashboardShell } from "./dashboard-shell";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { AuthProvider, useAuth } from "@/hooks/use-auth";
import { Sidebar } from "@/components/layout/sidebar";
import { Header } from "@/components/layout/header";
function DashboardShell({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth();
const router = useRouter();
useEffect(() => {
if (!loading && !user) {
router.push("/login");
}
}, [user, loading, router]);
if (loading) {
return (
<div className="flex h-screen items-center justify-center bg-slate-950">
<div className="flex flex-col items-center gap-3">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-emerald-500 border-t-transparent" />
<p className="text-sm text-slate-400">Loading...</p>
</div>
</div>
);
}
if (!user) return null;
return (
<div className="flex h-screen overflow-hidden bg-slate-950">
<Sidebar />
<div className="flex flex-1 flex-col overflow-hidden">
<Header />
<main className="flex-1 overflow-y-auto p-6">{children}</main>
</div>
</div>
);
}
// Server layout whose only job is to declare "do not index" metadata
// for the authed app. robots.ts already disallows these paths at the
// crawler-level and middleware redirects unauthenticated visitors, so
// this is belt-and-suspenders — but SEO-critical if a URL ever leaks
// via a link shared externally.
export const metadata: Metadata = {
robots: {
index: false,
follow: false,
nocache: true,
googleBot: {
index: false,
follow: false,
noimageindex: true,
},
},
};
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<AuthProvider>
<DashboardShell>{children}</DashboardShell>
</AuthProvider>
);
return <DashboardShell>{children}</DashboardShell>;
}

View File

@ -1,16 +1,88 @@
import type { Metadata } from "next";
import type { Metadata, Viewport } from "next";
import { Inter } from "next/font/google";
import { Toaster } from "sonner";
import "./globals.css";
import {
OG_IMAGE_ALT,
SITE_DESCRIPTION,
SITE_KEYWORDS,
SITE_NAME,
SITE_TAGLINE,
SITE_URL,
} from "@/lib/seo/site-config";
const inter = Inter({
variable: "--font-sans",
subsets: ["latin"],
});
// Consolidated site-wide metadata. Page-specific metadata (landing,
// auth pages, etc.) override the narrower fields (title, description,
// robots); defaults defined here are inherited everywhere else so we
// never ship a page without OpenGraph / Twitter coverage.
export const metadata: Metadata = {
title: "WaCRM - WhatsApp CRM",
description: "WhatsApp CRM for managing conversations, contacts, and pipelines",
metadataBase: new URL(SITE_URL),
title: {
default: `${SITE_NAME} — ${SITE_TAGLINE}`,
template: `%s — ${SITE_NAME}`,
},
description: SITE_DESCRIPTION,
keywords: SITE_KEYWORDS,
applicationName: SITE_NAME,
authors: [{ name: SITE_NAME, url: SITE_URL }],
creator: SITE_NAME,
publisher: SITE_NAME,
category: "Business Software",
alternates: {
canonical: "/",
},
openGraph: {
type: "website",
siteName: SITE_NAME,
title: `${SITE_NAME} — ${SITE_TAGLINE}`,
description: SITE_DESCRIPTION,
url: SITE_URL,
locale: "en_US",
images: [
{
url: "/opengraph-image",
width: 1200,
height: 630,
alt: OG_IMAGE_ALT,
},
],
},
twitter: {
card: "summary_large_image",
title: `${SITE_NAME} — ${SITE_TAGLINE}`,
description: SITE_DESCRIPTION,
images: ["/opengraph-image"],
},
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
"max-image-preview": "large",
"max-snippet": -1,
},
},
icons: {
icon: [{ url: "/icon" }],
shortcut: ["/favicon.ico"],
},
formatDetection: {
email: false,
address: false,
telephone: false,
},
};
// Dark theme color for the mobile browser chrome.
export const viewport: Viewport = {
themeColor: "#020617",
colorScheme: "dark",
};
export default function RootLayout({

103
src/app/opengraph-image.tsx Normal file
View File

@ -0,0 +1,103 @@
import { ImageResponse } from 'next/og'
import { SITE_NAME, SITE_TAGLINE } from '@/lib/seo/site-config'
/**
* Auto-generated OG image served at /opengraph-image.
* Next renders this JSX to a PNG at build time, caches it, and wires
* the URL into the default `<meta property="og:image">` for every
* page that inherits the root metadata.
*
* Facebook / LinkedIn / Slack / Twitter all fetch this when the link
* is shared, so keep it readable at thumbnail sizes (no tiny text,
* high contrast).
*/
export const alt = `${SITE_NAME} — ${SITE_TAGLINE}`
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'
export default async function Image() {
return new ImageResponse(
(
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
padding: 80,
background:
'linear-gradient(135deg, #020617 0%, #0f172a 55%, #064e3b 100%)',
color: 'white',
fontFamily: 'sans-serif',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 18 }}>
<div
style={{
width: 64,
height: 64,
borderRadius: 14,
background: '#10b981',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'white',
fontSize: 34,
fontWeight: 700,
}}
>
W
</div>
<div style={{ fontSize: 36, fontWeight: 700, letterSpacing: -0.5 }}>
{SITE_NAME}
</div>
</div>
<div
style={{
marginTop: 60,
fontSize: 84,
fontWeight: 800,
lineHeight: 1.02,
letterSpacing: -2,
maxWidth: 1000,
}}
>
Run your WhatsApp business from{' '}
<span style={{ color: '#34d399' }}>one inbox.</span>
</div>
<div
style={{
marginTop: 28,
fontSize: 28,
color: '#94a3b8',
maxWidth: 900,
lineHeight: 1.3,
}}
>
Shared inbox, sales pipelines, broadcasts, and no-code automations —
built on the official WhatsApp Business API.
</div>
<div
style={{
marginTop: 56,
display: 'flex',
gap: 24,
fontSize: 22,
color: '#cbd5e1',
}}
>
<span>✓ Shared inbox</span>
<span>✓ Automations</span>
<span>✓ Pipelines</span>
<span>✓ Analytics</span>
</div>
</div>
),
size,
)
}

View File

@ -12,11 +12,22 @@ import { InboxMock } from '@/components/landing/mock/inbox-mock'
import { PipelineMock } from '@/components/landing/mock/pipeline-mock'
import { AutomationMock } from '@/components/landing/mock/automation-mock'
import { AnalyticsMock } from '@/components/landing/mock/analytics-mock'
import { JsonLd } from '@/components/seo/json-ld'
import { landingPageLd } from '@/lib/seo/structured-data'
import { SITE_DESCRIPTION, SITE_NAME, SITE_TAGLINE } from '@/lib/seo/site-config'
// Landing-specific metadata. Most fields are inherited from the root
// layout's metadata — we override title (so the hero copy shows in
// SERPs / tab titles) and set an explicit canonical for "/" to avoid
// trailing-slash duplicate-content signals.
export const metadata: Metadata = {
title: 'WaCRM — Run your WhatsApp business from one inbox',
description:
'Shared inbox, contact hub, sales pipelines, broadcasts, and no-code automations for WhatsApp. Built on the official WhatsApp Business API.',
title: {
absolute: `${SITE_NAME} — ${SITE_TAGLINE}`,
},
description: SITE_DESCRIPTION,
alternates: {
canonical: '/',
},
}
// Marketing landing. Visible at / for everyone (auth redirect that
@ -25,6 +36,9 @@ export const metadata: Metadata = {
export default function LandingPage() {
return (
<div className="bg-slate-950 text-slate-100">
{/* JSON-LD — WebSite, Organization, SoftwareApplication, FAQPage.
Emitted before any visible content so crawlers hit it first. */}
<JsonLd data={landingPageLd()} />
<LandingNav />
<main>
<Hero />

35
src/app/robots.ts Normal file
View File

@ -0,0 +1,35 @@
import type { MetadataRoute } from 'next'
import { SITE_URL } from '@/lib/seo/site-config'
/**
* robots.txt served at /robots.txt. We explicitly disallow every
* gated area so crawlers don't spend their budget on pages they'll
* bounce off. Auth pages are closed off too — no SEO value, and
* we don't want "Sign in to WaCRM" competing with the landing page
* in SERPs.
*/
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: '*',
allow: '/',
disallow: [
'/api/',
'/dashboard',
'/inbox',
'/contacts',
'/pipelines',
'/broadcasts',
'/automations',
'/settings',
'/login',
'/signup',
'/forgot-password',
],
},
],
sitemap: `${SITE_URL}/sitemap.xml`,
host: SITE_URL,
}
}

20
src/app/sitemap.ts Normal file
View File

@ -0,0 +1,20 @@
import type { MetadataRoute } from 'next'
import { SITE_URL } from '@/lib/seo/site-config'
/**
* Sitemap served at /sitemap.xml. Only lists public, indexable pages
* — auth pages are explicitly noindex, and every route under /app is
* gated. The landing anchors (#features, #how-it-works, #faq) don't
* belong here; fragments aren't a sitemap concept.
*/
export default function sitemap(): MetadataRoute.Sitemap {
const now = new Date()
return [
{
url: `${SITE_URL}/`,
lastModified: now,
changeFrequency: 'weekly',
priority: 1,
},
]
}

View File

@ -3,38 +3,9 @@
import { useState } from 'react'
import { ChevronDown } from 'lucide-react'
import { Section, SectionHeader } from './section'
import { FAQ_ITEMS } from '@/lib/seo/faq-data'
import { cn } from '@/lib/utils'
// Answering real objections a small-business owner has before signing
// up. Order matters — setup friction is usually the #1 concern, cost
// #2, data ownership #3.
const QA = [
{
q: 'Do I need my own WhatsApp Business API access?',
a: "Yes. WaCRM plugs into your existing Meta WhatsApp Business setup — you bring the phone number and access token, we provide the CRM tooling around it. Any Meta-approved BSP (Business Solution Provider) works.",
},
{
q: 'Can my whole team share one WhatsApp number?',
a: 'Yes. Assign conversations to specific agents, track who is responding to what, and hand off threads without losing context. All your agents work from a single shared inbox.',
},
{
q: 'How fast is setup?',
a: 'Most teams are live in under 30 minutes once their WhatsApp Business number has been approved by Meta. Paste your credentials in Settings, import contacts if you have them, and start replying.',
},
{
q: 'Who owns the data?',
a: 'You do. Everything lives in your own Supabase project — contacts, conversations, deals, automation logs. Export it anytime; there is no lock-in on the data layer.',
},
{
q: 'Can I send bulk messages and automated replies?',
a: 'Yes. Broadcasts send Meta-approved templates to segmented contact lists with delivery tracking. Automations run no-code flows triggered by new contacts, keywords, tag changes, and more.',
},
{
q: 'What about message templates?',
a: 'Templates you create in Meta are synced automatically. Use them from the inbox, broadcasts, or inside an automation step.',
},
]
export function FAQ() {
const [openIdx, setOpenIdx] = useState<number | null>(0)
return (
@ -46,7 +17,7 @@ export function FAQ() {
/>
<div className="mx-auto max-w-3xl divide-y divide-slate-800 rounded-xl border border-slate-800 bg-slate-900/40">
{QA.map((item, i) => {
{FAQ_ITEMS.map((item, i) => {
const isOpen = openIdx === i
return (
<div key={item.q}>

View File

@ -0,0 +1,22 @@
/**
* Emits one <script type="application/ld+json"> per schema node.
* Google recommends separate tags per schema type over one combined
* @graph blob, to make it easier for them to parse selectively.
*
* Uses dangerouslySetInnerHTML because React escapes the < / > in
* script bodies otherwise, which breaks JSON-LD in subtle ways.
* The input is JSON.stringify'd locally — no user input flows here.
*/
export function JsonLd({ data }: { data: Record<string, unknown>[] }) {
return (
<>
{data.map((node, i) => (
<script
key={i}
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(node) }}
/>
))}
</>
)
}

36
src/lib/seo/faq-data.ts Normal file
View File

@ -0,0 +1,36 @@
/**
* FAQ content used by both the rendered FAQ accordion on the landing
* page AND the FAQPage JSON-LD emitted for rich-result eligibility
* on search engines. Single source so they never drift.
*/
export interface FaqItem {
q: string
a: string
}
export const FAQ_ITEMS: FaqItem[] = [
{
q: 'Do I need my own WhatsApp Business API access?',
a: 'Yes. WaCRM plugs into your existing Meta WhatsApp Business setup — you bring the phone number and access token, we provide the CRM tooling around it. Any Meta-approved BSP (Business Solution Provider) works.',
},
{
q: 'Can my whole team share one WhatsApp number?',
a: 'Yes. Assign conversations to specific agents, track who is responding to what, and hand off threads without losing context. All your agents work from a single shared inbox.',
},
{
q: 'How fast is setup?',
a: 'Most teams are live in under 30 minutes once their WhatsApp Business number has been approved by Meta. Paste your credentials in Settings, import contacts if you have them, and start replying.',
},
{
q: 'Who owns the data?',
a: 'You do. Everything lives in your own Supabase project — contacts, conversations, deals, automation logs. Export it anytime; there is no lock-in on the data layer.',
},
{
q: 'Can I send bulk messages and automated replies?',
a: 'Yes. Broadcasts send Meta-approved templates to segmented contact lists with delivery tracking. Automations run no-code flows triggered by new contacts, keywords, tag changes, and more.',
},
{
q: 'What about message templates?',
a: 'Templates you create in Meta are synced automatically. Use them from the inbox, broadcasts, or inside an automation step.',
},
]

View File

@ -0,0 +1,56 @@
/**
* Single source of truth for anything the SEO layer needs to know
* about the site — base URL, brand name, headline copy, social
* handles. Import from here everywhere (metadata, JSON-LD, sitemap,
* robots, OG image) so a rename or domain change is a one-file edit.
*/
/** Production origin — used to resolve all absolute URLs in metadata. */
export const SITE_URL =
process.env.NEXT_PUBLIC_SITE_URL?.replace(/\/$/, '') || 'https://wacrm.tech'
export const SITE_NAME = 'WaCRM'
export const SITE_TAGLINE = 'Run your WhatsApp business from one inbox'
/** Default description — reused by layout metadata and structured data. */
export const SITE_DESCRIPTION =
'WaCRM is a WhatsApp CRM for small teams: shared inbox, contact hub, sales pipelines, broadcasts, and no-code automations — built on the official WhatsApp Business API.'
/**
* Keyword targets. Search engines largely ignore the meta keywords
* field, but a couple still use it and it's a cheap inclusion.
*/
export const SITE_KEYWORDS = [
'WhatsApp CRM',
'WhatsApp Business CRM',
'WhatsApp shared inbox',
'WhatsApp Business API',
'WhatsApp automation',
'WhatsApp broadcast',
'customer messaging platform',
'sales pipeline CRM',
'team inbox',
]
/** Public hero image for OG / Twitter previews. */
export const OG_IMAGE_PATH = '/opengraph-image'
export const OG_IMAGE_ALT = `${SITE_NAME} — ${SITE_TAGLINE}`
/** Organization info surfaced in JSON-LD. */
export const ORG_INFO = {
name: SITE_NAME,
legalName: 'WaCRM',
url: SITE_URL,
logo: `${SITE_URL}/icon`,
}
/**
* Helper: absolute-URL a relative path using SITE_URL.
* Use this instead of string concat so trailing-slash handling stays
* consistent.
*/
export function absoluteUrl(path = '/'): string {
const trimmed = path.startsWith('/') ? path : `/${path}`
return `${SITE_URL}${trimmed}`
}

View File

@ -0,0 +1,110 @@
import {
absoluteUrl,
ORG_INFO,
SITE_DESCRIPTION,
SITE_NAME,
SITE_URL,
} from './site-config'
import { FAQ_ITEMS } from './faq-data'
/**
* Builders for the JSON-LD blobs we inject on the landing page. Each
* returns a plain object — the consumer wraps it in <script
* type="application/ld+json">. Keeping them typed as `Record<string,
* unknown>` so schema.org shapes don't leak into the rest of the app.
*/
type Ld = Record<string, unknown>
/** Generic site-level node. Helps search engines map the domain. */
export function websiteLd(): Ld {
return {
'@context': 'https://schema.org',
'@type': 'WebSite',
name: SITE_NAME,
url: SITE_URL,
description: SITE_DESCRIPTION,
inLanguage: 'en',
}
}
/** Publisher / brand. Paired with the WebSite node. */
export function organizationLd(): Ld {
return {
'@context': 'https://schema.org',
'@type': 'Organization',
name: ORG_INFO.name,
legalName: ORG_INFO.legalName,
url: ORG_INFO.url,
logo: ORG_INFO.logo,
}
}
/**
* Core product node. SoftwareApplication is the recommended type for
* web-based SaaS in Google's Rich Results guidelines. The free-tier
* `offers` block enables a "Free" badge in SERPs where eligible.
*/
export function softwareApplicationLd(): Ld {
return {
'@context': 'https://schema.org',
'@type': 'SoftwareApplication',
name: SITE_NAME,
description: SITE_DESCRIPTION,
applicationCategory: 'BusinessApplication',
operatingSystem: 'Web',
url: SITE_URL,
offers: {
'@type': 'Offer',
price: '0',
priceCurrency: 'USD',
availability: 'https://schema.org/InStock',
},
featureList: [
'Shared WhatsApp inbox',
'Contact management with tags and custom fields',
'Sales pipelines with kanban board',
'Broadcast campaigns via approved WhatsApp templates',
'No-code workflow automations',
'Real-time analytics dashboard',
],
}
}
/**
* FAQPage — eligible for the FAQ rich result on Google.
* We build this from the same source the UI renders, so answers
* in SERP match answers on the page.
*/
export function faqPageLd(): Ld {
return {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: FAQ_ITEMS.map((item) => ({
'@type': 'Question',
name: item.q,
acceptedAnswer: {
'@type': 'Answer',
text: item.a,
},
})),
}
}
/**
* Convenience: an array of the nodes we want on the landing page.
* The consumer component iterates and emits one <script> per node
* (Google prefers one schema per script tag).
*/
export function landingPageLd(): Ld[] {
return [
websiteLd(),
organizationLd(),
softwareApplicationLd(),
faqPageLd(),
]
}
// Kept exported for discoverability; unused by runtime code but handy
// for anyone building additional pages that need an absolute URL in
// their JSON-LD.
export { absoluteUrl }