From 08fd8e320454258b602e0610cb8bb79d6342f125 Mon Sep 17 00:00:00 2001 From: omercelikdev Date: Fri, 11 Sep 2026 11:53:56 +0300 Subject: [PATCH 01/12] fix: clear lint errors and prerender metadata routes at build time - theme toggle: useSyncExternalStore instead of setState in an effect - typewriter: schedule the phrase advance instead of a synchronous setState - lang switcher: move the cookie write to module scope, use location.assign - not-found: next/link instead of a raw anchor - robots, sitemap, OG images: force-static (+ full locale x slug params) Co-Authored-By: Claude Opus 5 --- eslint.config.mjs | 15 +++++++++++++++ src/app/[locale]/opengraph-image.tsx | 8 ++++++++ .../writings/[slug]/opengraph-image.tsx | 10 +++++++++- src/app/not-found.tsx | 5 +++-- src/app/robots.ts | 3 +++ src/app/sitemap.ts | 4 ++++ src/components/layout/lang-switcher.tsx | 13 +++++++++---- src/components/motion/typewriter.tsx | 10 +++++++--- src/components/theme/theme-toggle.tsx | 19 ++++++++++++------- 9 files changed, 70 insertions(+), 17 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 05e726d..33bc2a6 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -13,6 +13,21 @@ const eslintConfig = defineConfig([ "build/**", "next-env.d.ts", ]), + { + files: ["**/*.{js,mjs,cjs,jsx,ts,tsx,mts,cts}"], + rules: { + // Leading underscore marks a binding that exists only to be discarded + // (e.g. omitting a key via rest destructuring). + "@typescript-eslint/no-unused-vars": [ + "warn", + { + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + ignoreRestSiblings: true, + }, + ], + }, + }, ]); export default eslintConfig; diff --git a/src/app/[locale]/opengraph-image.tsx b/src/app/[locale]/opengraph-image.tsx index e10331d..ddc4827 100644 --- a/src/app/[locale]/opengraph-image.tsx +++ b/src/app/[locale]/opengraph-image.tsx @@ -1,10 +1,18 @@ import { ImageResponse } from "next/og"; import { site } from "@/config/site"; +import { routing } from "@/i18n/routing"; export const alt = site.name; export const size = { width: 1200, height: 630 }; export const contentType = "image/png"; +// Rendered to a PNG at build time — the card is identical for every locale. +export const dynamic = "force-static"; + +export function generateStaticParams() { + return routing.locales.map((locale) => ({ locale })); +} + // Static social card. Colours are inlined (OG images can't read CSS variables); // they mirror the light theme tokens — update if you re-skin the brand. export default function OpenGraphImage() { diff --git a/src/app/[locale]/writings/[slug]/opengraph-image.tsx b/src/app/[locale]/writings/[slug]/opengraph-image.tsx index abbc966..59dd613 100644 --- a/src/app/[locale]/writings/[slug]/opengraph-image.tsx +++ b/src/app/[locale]/writings/[slug]/opengraph-image.tsx @@ -1,14 +1,22 @@ import { ImageResponse } from "next/og"; import { getWritingBySlug, getWritingSlugs } from "@/lib/writings"; import { site } from "@/config/site"; +import { routing } from "@/i18n/routing"; export const alt = "Writing — omercelik.dev"; export const size = { width: 1200, height: 630 }; export const contentType = "image/png"; +// Rendered to a PNG at build time, one per article. +export const dynamic = "force-static"; + export async function generateStaticParams() { const slugs = await getWritingSlugs(); - return slugs.map((slug) => ({ slug })); + // Metadata image routes don't inherit the locale from the layout's params + // the way pages do, so spell out the full locale × slug matrix. + return routing.locales.flatMap((locale) => + slugs.map((slug) => ({ locale, slug })), + ); } // Per-article social card so shared links (LinkedIn, X) show the post title. diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx index b15fe7f..5715c32 100644 --- a/src/app/not-found.tsx +++ b/src/app/not-found.tsx @@ -1,3 +1,4 @@ +import Link from "next/link"; import "./globals.css"; /** Global fallback for routes that never resolved to a locale. Has its own @@ -12,12 +13,12 @@ export default function GlobalNotFound() {

The page you are looking for does not exist or has moved.

- Back home - + diff --git a/src/app/robots.ts b/src/app/robots.ts index c5f80be..08db9ef 100644 --- a/src/app/robots.ts +++ b/src/app/robots.ts @@ -1,6 +1,9 @@ import type { MetadataRoute } from "next"; import { site } from "@/config/site"; +// Emitted as a file at build time, like feed.xml — no request-time work. +export const dynamic = "force-static"; + export default function robots(): MetadataRoute.Robots { return { rules: { userAgent: "*", allow: "/" }, diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts index 404ade1..e6cdfa7 100644 --- a/src/app/sitemap.ts +++ b/src/app/sitemap.ts @@ -3,6 +3,10 @@ import { routing } from "@/i18n/routing"; import { localeUrl } from "@/lib/seo"; import { getAllWritings } from "@/lib/writings"; +// Emitted as a file at build time, like feed.xml — `lastModified` is the +// deploy time, which is what a rebuild-on-publish site wants anyway. +export const dynamic = "force-static"; + const STATIC_PATHS = ["/", "/products", "/writings", "/about"]; export default async function sitemap(): Promise { diff --git a/src/components/layout/lang-switcher.tsx b/src/components/layout/lang-switcher.tsx index cd01440..a418442 100644 --- a/src/components/layout/lang-switcher.tsx +++ b/src/components/layout/lang-switcher.tsx @@ -16,6 +16,13 @@ const NATIVE_NAMES: Record = { ja: "日本語", }; +/** Persist the choice so next-intl's proxy doesn't redirect the prefix-less + * default-locale path ("/") back to the previous locale. Lives at module + * scope: writing to `document` is a browser side effect, not render work. */ +function rememberLocale(locale: Locale) { + document.cookie = `NEXT_LOCALE=${locale}; path=/; max-age=31536000; samesite=lax`; +} + export function LangSwitcher() { const t = useTranslations("lang"); const activeLocale = useLocale() as Locale; @@ -34,13 +41,11 @@ export function LangSwitcher() { function select(locale: Locale) { setOpen(false); if (locale === activeLocale) return; - // Persist the choice so next-intl's middleware doesn't redirect the - // prefix-less default-locale path ("/") back to the previous locale. - document.cookie = `NEXT_LOCALE=${locale}; path=/; max-age=31536000; samesite=lax`; + rememberLocale(locale); // Full page load (not a soft nav): the root [locale] layout re-renders on // the server, so its inline scripts (theme init) never re-render on the // client and React 19's "script tag" warning can't fire. - window.location.href = getPathname({ href: pathname, locale }); + window.location.assign(getPathname({ href: pathname, locale })); } return ( diff --git a/src/components/motion/typewriter.tsx b/src/components/motion/typewriter.tsx index 3fc2e78..142ecbb 100644 --- a/src/components/motion/typewriter.tsx +++ b/src/components/motion/typewriter.tsx @@ -28,9 +28,13 @@ export function Typewriter({ return () => clearTimeout(t); } if (deleting && text === "") { - setDeleting(false); - setIndex((i) => (i + 1) % phrases.length); - return; + // A short beat before the next phrase starts typing. Scheduled rather + // than set synchronously so the effect never cascades a render. + const t = setTimeout(() => { + setDeleting(false); + setIndex((i) => (i + 1) % phrases.length); + }, typingMs); + return () => clearTimeout(t); } const t = setTimeout( diff --git a/src/components/theme/theme-toggle.tsx b/src/components/theme/theme-toggle.tsx index 9f0ba84..3b41971 100644 --- a/src/components/theme/theme-toggle.tsx +++ b/src/components/theme/theme-toggle.tsx @@ -1,20 +1,25 @@ "use client"; -import { useEffect, useState } from "react"; +import { useSyncExternalStore } from "react"; import { Moon, Sun } from "lucide-react"; import { useTranslations } from "next-intl"; import { useTheme } from "next-themes"; +const noopSubscribe = () => () => {}; + export function ThemeToggle() { const t = useTranslations("theme"); const { resolvedTheme, setTheme } = useTheme(); - const [mounted, setMounted] = useState(false); - - useEffect(() => { - setMounted(true); - }, []); + // `resolvedTheme` is only known on the client, so the first client render + // must match the server's. `false` on the server, `true` once hydrated — + // the sanctioned way to express that without setState inside an effect. + const hydrated = useSyncExternalStore( + noopSubscribe, + () => true, + () => false, + ); - const isDark = mounted && resolvedTheme === "dark"; + const isDark = hydrated && resolvedTheme === "dark"; return (