I spent the last couple of weeks building a website template for dental clinics, mostly to learn the Next.js 16 App Router properly: static rendering with next-intl, next/og, the new proxy.ts, Tailwind 4. The free version is MIT on GitHub, the full one has booking, a gallery and a second language.
- Free lite (MIT): https://github.com/aivnaov123/lumen-dental-lite
- Live demo of the full version: https://lumen-dental-demo-brown.vercel.app
Here are the five things that cost me the most time, so they don't cost you.
1. Every page silently became dynamic
With next-intl and a [locale] segment, I had generateStaticParams in the layout and assumed everything was prerendered. The build output said otherwise: every route was ƒ (Dynamic).
The cause: getLocale() (and anything that reads the locale) falls back to request headers unless each page calls setRequestLocale(locale) — the layout alone isn't enough.
export default async function ServicesPage({ params }: { params: Promise<{ locale: string }> }) { setRequestLocale((await params).locale); const { copy } = await getContent(); // ... } After adding it to all 11 pages: 30 static pages, 60 ms TTFB on Vercel. Check your build output for ● vs ƒ — it's easy to miss.
2. The root opengraph-image.tsx 404'd in production
I put opengraph-image.tsx in src/app/, as the docs show. Locally it worked; on Vercel /opengraph-image returned 404 and no og:image tag was emitted.
The i18n middleware matcher excluded files with an extension (.*\..*) — but /opengraph-image has none, so it was treated as a page and routed to the locale handler.
Fix: move it inside the segment, src/app/[locale]/opengraph-image.tsx, read params.locale, and you get a per-language OG image for free:
export function generateStaticParams() { return routing.locales.map((locale) => ({ locale })); } export default async function OpenGraphImage({ params }: { params: Promise<{ locale: string }> }) { const { locale } = await params; const { copy } = contentFor(locale); return new ImageResponse(/* ... */); } 3. toISOString().slice(0, 10) lost a day
The booking calendar stored the selected date as date.toISOString().slice(0, 10). In Sofia (UTC+2), clicking Monday the 23rd produced 2026-11-22 — Sunday, which the clinic is closed on. Classic, and I still wrote it.
const toISODate = (d: Date) => { const pad = (n: number) => String(n).padStart(2, "0"); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; }; Anything that represents a calendar day should never go through UTC.
4. The hero was invisible until JavaScript loaded
Lighthouse mobile gave the home page a 62. LCP was 4.6 s even though the hero image was 23 KB. The culprit was Framer Motion: initial={{ opacity: 0 }} on the hero meant the largest element painted only after hydration on a throttled CPU.
Two changes took it to 91:
- Above the fold, animate transform only, in CSS, so first paint isn't gated on JS:
@keyframes rise { from { transform: translateY(18px) } to { transform: translateY(0) } } - Below the fold, keep Framer Motion but load it lazily with
LazyMotion+minstead ofmotion— JS on the home page dropped from 342 KB to 179 KB.
Also: useSearchParams() inside Suspense with an empty fallback made the booking page's footer jump (CLS 0.47). A skeleton with the same height fixed it.
5. Custom 404 never showed
[locale]/not-found.tsx is only rendered when something calls notFound(). Unknown URLs under a dynamic segment don't — Next served its plain default 404. A catch-all route solves it:
// src/app/[locale]/[...rest]/page.tsx import { notFound } from "next/navigation"; export default function CatchAll() { notFound(); } What's in the template
All copy lives in src/content/<locale>/*.ts, shared config in src/config/. Nine services, six doctors, booking with calendar/slots/validation and email or webhook delivery, before/after sliders, financing section, dark mode, EN + BG, schema.org Dentist JSON-LD, sitemap/robots/manifest. Lighthouse 100 desktop, 90+ mobile.
If you build sites for clinics, the full version is a one-time purchase (link in the repo README). If you just want to poke at the Next.js 16 setup, the lite is free — PRs and issues welcome.
Happy to answer questions about any of the above in the comments.


