diff --git a/frontend/src/app/(auth)/forgot-password/page.tsx b/frontend/src/app/(auth)/forgot-password/page.tsx
index 68ece78..add9c28 100644
--- a/frontend/src/app/(auth)/forgot-password/page.tsx
+++ b/frontend/src/app/(auth)/forgot-password/page.tsx
@@ -2,14 +2,15 @@ import { Suspense } from 'react';
import type { Metadata } from 'next';
import Loader from '~/components/loader';
import { ForgotPasswordForm } from './form';
+import { buildMetadata } from '~/lib/metadata';
-export const metadata: Metadata = {
+export const metadata: Metadata = buildMetadata({
title: 'Reset Password',
description:
'Reset your Retailytics password to regain access to your retail intelligence dashboards and store data.',
- alternates: { canonical: '/forgot-password' },
- robots: { index: false, follow: true },
-};
+ path: '/forgot-password',
+ index: false,
+});
export default async function ForgotPasswordPage() {
return (
diff --git a/frontend/src/app/(auth)/login/page.tsx b/frontend/src/app/(auth)/login/page.tsx
index 289d8bd..b077150 100644
--- a/frontend/src/app/(auth)/login/page.tsx
+++ b/frontend/src/app/(auth)/login/page.tsx
@@ -4,14 +4,15 @@ import type { Metadata } from 'next';
import { cache, Suspense } from 'react';
import Loader from '~/components/loader';
import { redirect } from 'next/navigation';
+import { buildMetadata } from '~/lib/metadata';
-export const metadata: Metadata = {
+export const metadata: Metadata = buildMetadata({
title: 'Sign In',
description:
'Sign in to your Retailytics account to access store enumeration dashboards, field data, and market intelligence reports.',
- alternates: { canonical: '/login' },
- robots: { index: false, follow: true },
-};
+ path: '/login',
+ index: false,
+});
const getSession = cache(() => auth());
diff --git a/frontend/src/app/(auth)/register/page.tsx b/frontend/src/app/(auth)/register/page.tsx
index 41b8d1d..61a3387 100644
--- a/frontend/src/app/(auth)/register/page.tsx
+++ b/frontend/src/app/(auth)/register/page.tsx
@@ -5,14 +5,15 @@ import { RegisterForm } from './form';
import { cache, Suspense } from 'react';
import Loader from '~/components/loader';
import { redirect } from 'next/navigation';
+import { buildMetadata } from '~/lib/metadata';
-export const metadata: Metadata = {
+export const metadata: Metadata = buildMetadata({
title: 'Create Account',
description:
'Create your Retailytics account to start collecting store data, validating field submissions, and turning local data into market intelligence.',
- alternates: { canonical: '/register' },
- robots: { index: false, follow: true },
-};
+ path: '/register',
+ index: false,
+});
const getSession = cache(() => auth());
diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx
index a91519d..c86fc7e 100644
--- a/frontend/src/app/layout.tsx
+++ b/frontend/src/app/layout.tsx
@@ -28,6 +28,21 @@ export const metadata: Metadata = {
alternates: {
canonical: '/',
},
+ openGraph: {
+ type: 'website',
+ siteName: siteConfig.name,
+ locale: siteConfig.locale,
+ url: siteConfig.url,
+ title: `${siteConfig.name} — ${siteConfig.tagline}`,
+ description: siteConfig.description,
+ },
+ twitter: {
+ card: 'summary_large_image',
+ site: siteConfig.twitter.handle,
+ creator: siteConfig.twitter.handle,
+ title: `${siteConfig.name} — ${siteConfig.tagline}`,
+ description: siteConfig.description,
+ },
};
export default function RootLayout({
diff --git a/frontend/src/app/opengraph-image.tsx b/frontend/src/app/opengraph-image.tsx
new file mode 100644
index 0000000..e44994a
--- /dev/null
+++ b/frontend/src/app/opengraph-image.tsx
@@ -0,0 +1,72 @@
+import { ImageResponse } from 'next/og';
+import { siteConfig } from '~/lib/site';
+
+export const alt = `${siteConfig.name} — ${siteConfig.tagline}`;
+export const size = { width: 1200, height: 630 };
+export const contentType = 'image/png';
+
+export default function OpengraphImage() {
+ return new ImageResponse(
+
+
+ {siteConfig.name}
+
+
+
+
+ {siteConfig.tagline}
+
+
+ Store enumeration, field data collection, and market analysis in one
+ platform.
+
+
+
+
+ {siteConfig.url.replace(/^https?:\/\//, '')}
+ by {siteConfig.legalName}
+
+
,
+ { ...size },
+ );
+}
diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx
index 5578466..ea2cbb7 100644
--- a/frontend/src/app/page.tsx
+++ b/frontend/src/app/page.tsx
@@ -1,16 +1,12 @@
import CTA from '~/components/cta';
import FAQ from '~/components/faq';
import Hero from '~/components/hero';
-import type { Metadata } from 'next';
-import { siteConfig } from '~/lib/site';
import Footer from '~/components/footer';
+import type { Metadata } from 'next';
+import { buildMetadata } from '~/lib/metadata';
import HowItWorks from '~/components/how-it-works';
-export const metadata: Metadata = {
- title: { absolute: `${siteConfig.name} — ${siteConfig.tagline}` },
- description: siteConfig.description,
- alternates: { canonical: '/' },
-};
+export const metadata: Metadata = buildMetadata({ path: '/' });
export default function Home() {
return (
diff --git a/frontend/src/app/twitter-image.tsx b/frontend/src/app/twitter-image.tsx
new file mode 100644
index 0000000..587376b
--- /dev/null
+++ b/frontend/src/app/twitter-image.tsx
@@ -0,0 +1 @@
+export { default, alt, size, contentType } from '~/app/opengraph-image';
diff --git a/frontend/src/lib/metadata.ts b/frontend/src/lib/metadata.ts
new file mode 100644
index 0000000..2d6e2ee
--- /dev/null
+++ b/frontend/src/lib/metadata.ts
@@ -0,0 +1,42 @@
+import type { Metadata } from 'next';
+import { siteConfig } from '~/lib/site';
+
+type BuildMetadataOptions = {
+ title?: string;
+ description?: string;
+ path?: string;
+ index?: boolean;
+};
+
+export function buildMetadata({
+ title,
+ description = siteConfig.description,
+ path = '/',
+ index = true,
+}: BuildMetadataOptions = {}): Metadata {
+ const socialTitle = title
+ ? `${title} · ${siteConfig.name}`
+ : `${siteConfig.name} — ${siteConfig.tagline}`;
+
+ return {
+ title: title ?? { absolute: socialTitle },
+ description,
+ alternates: { canonical: path },
+ ...(index ? {} : { robots: { index: false, follow: true } }),
+ openGraph: {
+ type: 'website',
+ siteName: siteConfig.name,
+ locale: siteConfig.locale,
+ url: path,
+ title: socialTitle,
+ description,
+ },
+ twitter: {
+ card: 'summary_large_image',
+ site: siteConfig.twitter.handle,
+ creator: siteConfig.twitter.handle,
+ title: socialTitle,
+ description,
+ },
+ };
+}
diff --git a/frontend/src/lib/site.ts b/frontend/src/lib/site.ts
index 57164f8..fd14b5b 100644
--- a/frontend/src/lib/site.ts
+++ b/frontend/src/lib/site.ts
@@ -1,37 +1,23 @@
import { env } from '~/env';
-/**
- * Central, single-source-of-truth metadata for the Retailytics marketing
- * surface. Reused by page metadata, Open Graph/Twitter cards, robots.txt,
- * sitemap.xml, JSON-LD structured data and the llms.txt manifest so the
- * product is described consistently everywhere search engines and AI agents
- * look.
- */
export const siteConfig = {
name: 'Retailytics',
- /** Parent organisation that builds and operates Retailytics. */
legalName: 'Ajared Research Inc.',
- /** Canonical, absolute production URL (no trailing slash). */
url: env.NEXT_PUBLIC_SITE_URL.replace(/\/$/, ''),
tagline: 'Turn Local Data into Market Intelligence',
description:
'Retailytics turns local store data into market intelligence — enumeration, field data collection, and analysis in one platform. Start free today.',
- /** Monitored contact address used on public pages and structured data. */
contactEmail: 'innovation@ajared.ca',
- /** Default social/OG share image (1200×630), served from /public. */
- ogImage: '/og.png',
locale: 'en_US',
twitter: {
handle: '@ajaREDiA',
},
- /** Profiles used for the Organization `sameAs` graph. */
social: {
twitter: 'https://twitter.com/ajaREDiA',
github: 'https://github.com/ajared',
parent: 'https://www.ajared.ng',
parentCa: 'https://www.ajared.ca',
},
- /** Keyword targets shared across pages. */
keywords: [
'retail intelligence',
'store enumeration',
@@ -46,7 +32,6 @@ export const siteConfig = {
export type SiteConfig = typeof siteConfig;
-/** Build an absolute URL from a site-relative path. */
export function absoluteUrl(path = '/'): string {
return `${siteConfig.url}${path.startsWith('/') ? path : `/${path}`}`;
}