diff --git a/TODO.md b/TODO.md
index 8d4c007..d6e56a8 100644
--- a/TODO.md
+++ b/TODO.md
@@ -51,11 +51,11 @@ This file is the single source of truth for roadmap and delivery progress.
- [x] [P1] Separate Next.js public app in monorepo
- [x] [P1] App Router + TypeScript + `src/` structure
-- [~] [P1] Public app connected to shared data layer
-- [ ] [P1] Localized route structure and middleware rules
-- [ ] [P2] Public layout system (header/footer/navigation)
-- [ ] [P1] Header banner rendering from CMS-managed content
-- [ ] [P2] Basic SEO defaults (metadata, OG, sitemap, robots)
+- [x] [P1] Public app connected to shared data layer
+- [x] [P1] Localized route structure and middleware rules
+- [x] [P2] Public layout system (header/footer/navigation)
+- [x] [P1] Header banner rendering from CMS-managed content
+- [x] [P2] Basic SEO defaults (metadata, OG, sitemap, robots)
### Testing
@@ -204,6 +204,7 @@ This file is the single source of truth for roadmap and delivery progress.
- [2026-02-10] E2E now runs with deterministic preparation (`test:e2e:prepare`: generate + migrate deploy + seed) before Playwright execution.
- [2026-02-10] CI quality workflow `.gitea/workflows/ci.yml` enforces `check`, `typecheck`, `test`, and `test:e2e` against a PostgreSQL service.
- [2026-02-10] Admin app now uses a shared shell with permission-aware navigation and dedicated IA routes (`/pages`, `/media`, `/users`, `/commissions`).
+- [2026-02-10] Public app now has a shared site layout (`banner/header/footer`), DB-backed header banner config, and SEO defaults (`metadata`, `robots`, `sitemap`).
## How We Use This File
diff --git a/apps/web/src/app/[locale]/about/page.tsx b/apps/web/src/app/[locale]/about/page.tsx
new file mode 100644
index 0000000..da3118f
--- /dev/null
+++ b/apps/web/src/app/[locale]/about/page.tsx
@@ -0,0 +1,13 @@
+import { getTranslations } from "next-intl/server"
+
+export default async function AboutPage() {
+ const t = await getTranslations("About")
+
+ return (
+
+ {t("badge")}
+ {t("title")}
+ {t("description")}
+
+ )
+}
diff --git a/apps/web/src/app/[locale]/contact/page.tsx b/apps/web/src/app/[locale]/contact/page.tsx
new file mode 100644
index 0000000..8970263
--- /dev/null
+++ b/apps/web/src/app/[locale]/contact/page.tsx
@@ -0,0 +1,13 @@
+import { getTranslations } from "next-intl/server"
+
+export default async function ContactPage() {
+ const t = await getTranslations("Contact")
+
+ return (
+
+ {t("badge")}
+ {t("title")}
+ {t("description")}
+
+ )
+}
diff --git a/apps/web/src/app/[locale]/layout.tsx b/apps/web/src/app/[locale]/layout.tsx
index d7bf5c4..7addb82 100644
--- a/apps/web/src/app/[locale]/layout.tsx
+++ b/apps/web/src/app/[locale]/layout.tsx
@@ -1,7 +1,12 @@
+import { getPublicHeaderBanner } from "@cms/db"
import { notFound } from "next/navigation"
import { hasLocale, NextIntlClientProvider } from "next-intl"
+import { getTranslations } from "next-intl/server"
import type { ReactNode } from "react"
+import { PublicHeaderBanner } from "@/components/public-header-banner"
+import { PublicSiteFooter } from "@/components/public-site-footer"
+import { PublicSiteHeader } from "@/components/public-site-header"
import { routing } from "@/i18n/routing"
import { Providers } from "../providers"
@@ -12,6 +17,28 @@ type LocaleLayoutProps = {
}>
}
+export async function generateMetadata({ params }: LocaleLayoutProps) {
+ const { locale } = await params
+
+ if (!hasLocale(routing.locales, locale)) {
+ return {}
+ }
+
+ const t = await getTranslations({
+ locale,
+ namespace: "Seo",
+ })
+
+ return {
+ title: t("title"),
+ description: t("description"),
+ openGraph: {
+ title: t("title"),
+ description: t("description"),
+ },
+ }
+}
+
export default async function LocaleLayout({ children, params }: LocaleLayoutProps) {
const { locale } = await params
@@ -19,9 +46,16 @@ export default async function LocaleLayout({ children, params }: LocaleLayoutPro
notFound()
}
+ const banner = await getPublicHeaderBanner()
+
return (
- {children}
+
+
+
+ {children}
+
+
)
}
diff --git a/apps/web/src/app/[locale]/page.tsx b/apps/web/src/app/[locale]/page.tsx
index ccc84c3..1b52219 100644
--- a/apps/web/src/app/[locale]/page.tsx
+++ b/apps/web/src/app/[locale]/page.tsx
@@ -2,20 +2,15 @@ import { listPosts } from "@cms/db"
import { Button } from "@cms/ui/button"
import { getTranslations } from "next-intl/server"
-import { LanguageSwitcher } from "@/components/language-switcher"
-
export const dynamic = "force-dynamic"
export default async function HomePage() {
const [posts, t] = await Promise.all([listPosts(), getTranslations("Home")])
return (
-
+
-
+ {t("badge")}
{t("title")}
{t("description")}
@@ -36,6 +31,6 @@ export default async function HomePage() {
))}
-
+
)
}
diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx
index b07fc12..f344f8a 100644
--- a/apps/web/src/app/layout.tsx
+++ b/apps/web/src/app/layout.tsx
@@ -3,9 +3,30 @@ import type { ReactNode } from "react"
import "./globals.css"
+const metadataBase = new URL(process.env.CMS_WEB_ORIGIN ?? "http://localhost:3000")
+
export const metadata: Metadata = {
- title: "CMS Web",
+ metadataBase,
+ title: {
+ default: "CMS Web",
+ template: "%s | CMS Web",
+ },
description: "Public frontend for the CMS monorepo",
+ applicationName: "CMS Web",
+ openGraph: {
+ type: "website",
+ siteName: "CMS Web",
+ title: "CMS Web",
+ description: "Public frontend for the CMS monorepo",
+ url: metadataBase,
+ },
+ alternates: {
+ canonical: "/",
+ },
+ robots: {
+ index: true,
+ follow: true,
+ },
}
export default function RootLayout({ children }: { children: ReactNode }) {
diff --git a/apps/web/src/app/robots.ts b/apps/web/src/app/robots.ts
new file mode 100644
index 0000000..77c45de
--- /dev/null
+++ b/apps/web/src/app/robots.ts
@@ -0,0 +1,13 @@
+import type { MetadataRoute } from "next"
+
+const baseUrl = process.env.CMS_WEB_ORIGIN ?? "http://localhost:3000"
+
+export default function robots(): MetadataRoute.Robots {
+ return {
+ rules: {
+ userAgent: "*",
+ allow: "/",
+ },
+ sitemap: `${baseUrl}/sitemap.xml`,
+ }
+}
diff --git a/apps/web/src/app/sitemap.ts b/apps/web/src/app/sitemap.ts
new file mode 100644
index 0000000..56de424
--- /dev/null
+++ b/apps/web/src/app/sitemap.ts
@@ -0,0 +1,14 @@
+import type { MetadataRoute } from "next"
+
+const baseUrl = process.env.CMS_WEB_ORIGIN ?? "http://localhost:3000"
+
+const publicRoutes = ["/", "/about", "/contact"]
+
+export default function sitemap(): MetadataRoute.Sitemap {
+ const now = new Date()
+
+ return publicRoutes.map((route) => ({
+ url: `${baseUrl}${route}`,
+ lastModified: now,
+ }))
+}
diff --git a/apps/web/src/components/public-header-banner.tsx b/apps/web/src/components/public-header-banner.tsx
new file mode 100644
index 0000000..23e9551
--- /dev/null
+++ b/apps/web/src/components/public-header-banner.tsx
@@ -0,0 +1,25 @@
+import type { PublicHeaderBanner as PublicHeaderBannerData } from "@cms/db"
+import Link from "next/link"
+
+type PublicHeaderBannerProps = {
+ banner: PublicHeaderBannerData | null
+}
+
+export function PublicHeaderBanner({ banner }: PublicHeaderBannerProps) {
+ if (!banner) {
+ return null
+ }
+
+ return (
+
+
+
{banner.message}
+ {banner.ctaLabel && banner.ctaHref ? (
+
+ {banner.ctaLabel}
+
+ ) : null}
+
+
+ )
+}
diff --git a/apps/web/src/components/public-site-footer.tsx b/apps/web/src/components/public-site-footer.tsx
new file mode 100644
index 0000000..aa3185d
--- /dev/null
+++ b/apps/web/src/components/public-site-footer.tsx
@@ -0,0 +1,21 @@
+"use client"
+
+import { useTranslations } from "next-intl"
+
+export function PublicSiteFooter() {
+ const t = useTranslations("Layout")
+ const year = new Date().getFullYear()
+
+ return (
+
+ )
+}
diff --git a/apps/web/src/components/public-site-header.tsx b/apps/web/src/components/public-site-header.tsx
new file mode 100644
index 0000000..cf5703d
--- /dev/null
+++ b/apps/web/src/components/public-site-header.tsx
@@ -0,0 +1,44 @@
+"use client"
+
+import { useTranslations } from "next-intl"
+
+import { Link } from "@/i18n/navigation"
+
+import { LanguageSwitcher } from "./language-switcher"
+
+export function PublicSiteHeader() {
+ const t = useTranslations("Layout")
+
+ const navItems = [
+ { href: "/", label: t("nav.home") },
+ { href: "/about", label: t("nav.about") },
+ { href: "/contact", label: t("nav.contact") },
+ ]
+
+ return (
+
+ )
+}
diff --git a/apps/web/src/messages/de.json b/apps/web/src/messages/de.json
index f7d4473..f7e84e1 100644
--- a/apps/web/src/messages/de.json
+++ b/apps/web/src/messages/de.json
@@ -15,5 +15,31 @@
"es": "Spanisch",
"fr": "Französisch"
}
+ },
+ "Layout": {
+ "brand": "CMS Web",
+ "nav": {
+ "home": "Start",
+ "about": "Über uns",
+ "contact": "Kontakt"
+ },
+ "footer": {
+ "copyright": "© {year} CMS Web",
+ "tagline": "Powered by Next.js, Bun, Prisma und TanStack."
+ }
+ },
+ "Seo": {
+ "title": "CMS Web",
+ "description": "Öffentliches Frontend für das CMS-Monorepo."
+ },
+ "About": {
+ "badge": "Über uns",
+ "title": "Über dieses Projekt",
+ "description": "Diese öffentliche App ist die Frontend-Oberfläche für CMS-gesteuerte Inhalte und kommende dynamische Seiten."
+ },
+ "Contact": {
+ "badge": "Kontakt",
+ "title": "Kontakt",
+ "description": "Kontakt- und Auftragsabläufe werden in den nächsten MVP-Schritten eingeführt."
}
}
diff --git a/apps/web/src/messages/en.json b/apps/web/src/messages/en.json
index 22b2dfc..3f3023a 100644
--- a/apps/web/src/messages/en.json
+++ b/apps/web/src/messages/en.json
@@ -15,5 +15,31 @@
"es": "Spanish",
"fr": "French"
}
+ },
+ "Layout": {
+ "brand": "CMS Web",
+ "nav": {
+ "home": "Home",
+ "about": "About",
+ "contact": "Contact"
+ },
+ "footer": {
+ "copyright": "© {year} CMS Web",
+ "tagline": "Powered by Next.js, Bun, Prisma, and TanStack."
+ }
+ },
+ "Seo": {
+ "title": "CMS Web",
+ "description": "Public frontend for the CMS monorepo."
+ },
+ "About": {
+ "badge": "About",
+ "title": "About this project",
+ "description": "This public app is the frontend surface for CMS-driven content and upcoming dynamic pages."
+ },
+ "Contact": {
+ "badge": "Contact",
+ "title": "Contact",
+ "description": "Contact and commission flows will be introduced in upcoming MVP steps."
}
}
diff --git a/apps/web/src/messages/es.json b/apps/web/src/messages/es.json
index a157de6..64e49dd 100644
--- a/apps/web/src/messages/es.json
+++ b/apps/web/src/messages/es.json
@@ -15,5 +15,31 @@
"es": "Español",
"fr": "Francés"
}
+ },
+ "Layout": {
+ "brand": "CMS Web",
+ "nav": {
+ "home": "Inicio",
+ "about": "Acerca de",
+ "contact": "Contacto"
+ },
+ "footer": {
+ "copyright": "© {year} CMS Web",
+ "tagline": "Impulsado por Next.js, Bun, Prisma y TanStack."
+ }
+ },
+ "Seo": {
+ "title": "CMS Web",
+ "description": "Frontend público para el monorepo CMS."
+ },
+ "About": {
+ "badge": "Acerca de",
+ "title": "Sobre este proyecto",
+ "description": "Esta app pública es la superficie frontend para contenido gestionado por CMS y próximas páginas dinámicas."
+ },
+ "Contact": {
+ "badge": "Contacto",
+ "title": "Contacto",
+ "description": "Los flujos de contacto y comisiones se incorporarán en los siguientes pasos del MVP."
}
}
diff --git a/apps/web/src/messages/fr.json b/apps/web/src/messages/fr.json
index 9817dee..f69a5b0 100644
--- a/apps/web/src/messages/fr.json
+++ b/apps/web/src/messages/fr.json
@@ -15,5 +15,31 @@
"es": "Espagnol",
"fr": "Français"
}
+ },
+ "Layout": {
+ "brand": "CMS Web",
+ "nav": {
+ "home": "Accueil",
+ "about": "À propos",
+ "contact": "Contact"
+ },
+ "footer": {
+ "copyright": "© {year} CMS Web",
+ "tagline": "Propulsé par Next.js, Bun, Prisma et TanStack."
+ }
+ },
+ "Seo": {
+ "title": "CMS Web",
+ "description": "Frontend public pour le monorepo CMS."
+ },
+ "About": {
+ "badge": "À propos",
+ "title": "À propos de ce projet",
+ "description": "Cette application publique est la surface frontend pour le contenu piloté par CMS et les futures pages dynamiques."
+ },
+ "Contact": {
+ "badge": "Contact",
+ "title": "Contact",
+ "description": "Les flux de contact et de commission seront introduits dans les prochaines étapes MVP."
}
}
diff --git a/packages/db/prisma/seed.ts b/packages/db/prisma/seed.ts
index 3b947a1..5daa7ec 100644
--- a/packages/db/prisma/seed.ts
+++ b/packages/db/prisma/seed.ts
@@ -12,6 +12,20 @@ async function main() {
status: "published",
},
})
+
+ await db.systemSetting.upsert({
+ where: { key: "public.header_banner" },
+ update: {},
+ create: {
+ key: "public.header_banner",
+ value: JSON.stringify({
+ enabled: true,
+ message: "New portfolio release is live.",
+ ctaLabel: "Open latest posts",
+ ctaHref: "/",
+ }),
+ },
+ })
}
main()
diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts
index 4f14908..8cc6c44 100644
--- a/packages/db/src/index.ts
+++ b/packages/db/src/index.ts
@@ -7,4 +7,9 @@ export {
registerPostCrudAuditHook,
updatePost,
} from "./posts"
-export { isAdminSelfRegistrationEnabled, setAdminSelfRegistrationEnabled } from "./settings"
+export type { PublicHeaderBanner } from "./settings"
+export {
+ getPublicHeaderBanner,
+ isAdminSelfRegistrationEnabled,
+ setAdminSelfRegistrationEnabled,
+} from "./settings"
diff --git a/packages/db/src/settings.ts b/packages/db/src/settings.ts
index fb3cd12..5d4ae33 100644
--- a/packages/db/src/settings.ts
+++ b/packages/db/src/settings.ts
@@ -1,6 +1,20 @@
import { db } from "./client"
const ADMIN_SELF_REGISTRATION_KEY = "admin.self_registration_enabled"
+const PUBLIC_HEADER_BANNER_KEY = "public.header_banner"
+
+type PublicHeaderBannerRecord = {
+ enabled: boolean
+ message: string
+ ctaLabel?: string
+ ctaHref?: string
+}
+
+export type PublicHeaderBanner = {
+ message: string
+ ctaLabel?: string
+ ctaHref?: string
+}
function resolveEnvFallback(): boolean {
return process.env.CMS_ADMIN_SELF_REGISTRATION_ENABLED === "true"
@@ -18,6 +32,25 @@ function parseStoredBoolean(value: string): boolean | null {
return null
}
+function parsePublicHeaderBanner(value: string): PublicHeaderBannerRecord | null {
+ try {
+ const parsed = JSON.parse(value) as Record
+
+ if (typeof parsed.enabled !== "boolean" || typeof parsed.message !== "string") {
+ return null
+ }
+
+ return {
+ enabled: parsed.enabled,
+ message: parsed.message,
+ ctaLabel: typeof parsed.ctaLabel === "string" ? parsed.ctaLabel : undefined,
+ ctaHref: typeof parsed.ctaHref === "string" ? parsed.ctaHref : undefined,
+ }
+ } catch {
+ return null
+ }
+}
+
export async function isAdminSelfRegistrationEnabled(): Promise {
try {
const setting = await db.systemSetting.findUnique({
@@ -54,3 +87,30 @@ export async function setAdminSelfRegistrationEnabled(enabled: boolean): Promise
},
})
}
+
+export async function getPublicHeaderBanner(): Promise {
+ try {
+ const setting = await db.systemSetting.findUnique({
+ where: { key: PUBLIC_HEADER_BANNER_KEY },
+ select: { value: true },
+ })
+
+ if (!setting) {
+ return null
+ }
+
+ const parsed = parsePublicHeaderBanner(setting.value)
+
+ if (!parsed || !parsed.enabled) {
+ return null
+ }
+
+ return {
+ message: parsed.message,
+ ctaLabel: parsed.ctaLabel,
+ ctaHref: parsed.ctaHref,
+ }
+ } catch {
+ return null
+ }
+}