perf: remove TipTap/ProseMirror from client bundle, lazy-load below-fold sections
All checks were successful
CI / CD / test-build (push) Successful in 10m11s
CI / CD / deploy-dev (push) Successful in 1m23s
CI / CD / deploy-production (push) Has been skipped

TipTap (ProseMirror) was causing:
- chunks 1007 (85 KiB) and 3207 (58 KiB) in the initial bundle
- Array.prototype.at/flat/flatMap, Object.fromEntries/hasOwn polyfills
  (ProseMirror bundles core-js for these — the 12 KiB legacy JS flag)
- 2+ seconds of main thread blocking on mobile

Fix: move HTML conversion to the server (API route) and pass the
resulting HTML string to the client, eliminating the need to import
richTextToSafeHtml (and transitively TipTap) in any client component.

Changes:
- app/api/content/page/route.ts: call richTextToSafeHtml server-side,
  add html: string to response alongside existing content
- app/components/RichTextClient.tsx: accept html string, remove all
  TipTap imports — TipTap/ProseMirror now has zero client bundle cost
- app/components/About.tsx, Contact.tsx: use cmsHtml from API
- app/legal-notice/page.tsx, privacy-policy/page.tsx: same
- app/components/ClientWrappers.tsx: change static imports of About,
  Projects, Contact, Footer to next/dynamic so their JS is in
  separate lazy-loaded chunks, not in the initial bundle

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 14:57:36 +01:00
parent 34a81a6437
commit 1c49289386
7 changed files with 38 additions and 37 deletions

View File

@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { getContentByKey } from "@/lib/content"; import { getContentByKey } from "@/lib/content";
import { getContentPage } from "@/lib/directus"; import { getContentPage } from "@/lib/directus";
import { richTextToSafeHtml } from "@/lib/richtext";
const CACHE_TTL = 300; // 5 minutes const CACHE_TTL = 300; // 5 minutes
@@ -17,6 +18,8 @@ export async function GET(request: NextRequest) {
// 1) Try Directus first // 1) Try Directus first
const directusPage = await getContentPage(key, locale); const directusPage = await getContentPage(key, locale);
if (directusPage) { if (directusPage) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const html = directusPage.content ? richTextToSafeHtml(directusPage.content as any) : "";
return NextResponse.json( return NextResponse.json(
{ {
content: { content: {
@@ -24,6 +27,7 @@ export async function GET(request: NextRequest) {
slug: directusPage.slug, slug: directusPage.slug,
locale: directusPage.locale || locale, locale: directusPage.locale || locale,
content: directusPage.content, content: directusPage.content,
html,
}, },
source: "directus", source: "directus",
}, },

View File

@@ -3,7 +3,6 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { Globe, Server, Wrench, Shield, Gamepad2, Code, Activity, Lightbulb, BookOpen, MessageSquare, ArrowRight, Tv, Plane, Camera, Stars, Music, Terminal, Cpu } from "lucide-react"; import { Globe, Server, Wrench, Shield, Gamepad2, Code, Activity, Lightbulb, BookOpen, MessageSquare, ArrowRight, Tv, Plane, Camera, Stars, Music, Terminal, Cpu } from "lucide-react";
import { useLocale, useTranslations } from "next-intl"; import { useLocale, useTranslations } from "next-intl";
import type { JSONContent } from "@tiptap/react";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
const RichTextClient = dynamic(() => import("./RichTextClient"), { ssr: false }); const RichTextClient = dynamic(() => import("./RichTextClient"), { ssr: false });
import CurrentlyReading from "./CurrentlyReading"; import CurrentlyReading from "./CurrentlyReading";
@@ -23,7 +22,7 @@ const iconMap: Record<string, LucideIcon> = {
const About = () => { const About = () => {
const locale = useLocale(); const locale = useLocale();
const t = useTranslations("home.about"); const t = useTranslations("home.about");
const [cmsDoc, setCmsDoc] = useState<JSONContent | null>(null); const [cmsHtml, setCmsHtml] = useState<string | null>(null);
const [techStack, setTechStack] = useState<TechStackCategory[]>([]); const [techStack, setTechStack] = useState<TechStackCategory[]>([]);
const [hobbies, setHobbies] = useState<Hobby[]>([]); const [hobbies, setHobbies] = useState<Hobby[]>([]);
const [snippets, setSnippets] = useState<Snippet[]>([]); const [snippets, setSnippets] = useState<Snippet[]>([]);
@@ -44,7 +43,7 @@ const About = () => {
]); ]);
const cmsData = await cmsRes.json(); const cmsData = await cmsRes.json();
if (cmsData?.content?.content) setCmsDoc(cmsData.content.content as JSONContent); if (cmsData?.content?.html) setCmsHtml(cmsData.content.html as string);
const techData = await techRes.json(); const techData = await techRes.json();
if (techData?.techStack) setTechStack(techData.techStack); if (techData?.techStack) setTechStack(techData.techStack);
@@ -93,8 +92,8 @@ const About = () => {
<Skeleton className="h-6 w-[95%]" /> <Skeleton className="h-6 w-[95%]" />
<Skeleton className="h-6 w-[90%]" /> <Skeleton className="h-6 w-[90%]" />
</div> </div>
) : cmsDoc ? ( ) : cmsHtml ? (
<RichTextClient doc={cmsDoc} /> <RichTextClient html={cmsHtml} />
) : ( ) : (
<p>{t("p1")} {t("p2")}</p> <p>{t("p1")} {t("p2")}</p>
)} )}

View File

@@ -6,10 +6,14 @@
*/ */
import { NextIntlClientProvider } from 'next-intl'; import { NextIntlClientProvider } from 'next-intl';
import About from './About'; import dynamic from 'next/dynamic';
import Projects from './Projects';
import Contact from './Contact'; // Lazy-load below-fold components so their JS doesn't block initial paint / LCP.
import Footer from './Footer'; // SSR stays on (default) so content is in the initial HTML for SEO.
const About = dynamic(() => import('./About'));
const Projects = dynamic(() => import('./Projects'));
const Contact = dynamic(() => import('./Contact'));
const Footer = dynamic(() => import('./Footer'));
import type { import type {
AboutTranslations, AboutTranslations,
ProjectsTranslations, ProjectsTranslations,

View File

@@ -5,7 +5,6 @@ import { motion } from "framer-motion";
import { Mail, MapPin, Send, Github, Linkedin } from "lucide-react"; import { Mail, MapPin, Send, Github, Linkedin } from "lucide-react";
import { useToast } from "@/components/Toast"; import { useToast } from "@/components/Toast";
import { useLocale, useTranslations } from "next-intl"; import { useLocale, useTranslations } from "next-intl";
import type { JSONContent } from "@tiptap/react";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
const RichTextClient = dynamic(() => import("./RichTextClient"), { ssr: false }); const RichTextClient = dynamic(() => import("./RichTextClient"), { ssr: false });
@@ -15,7 +14,7 @@ const Contact = () => {
const t = useTranslations("home.contact"); const t = useTranslations("home.contact");
const tForm = useTranslations("home.contact.form"); const tForm = useTranslations("home.contact.form");
const tInfo = useTranslations("home.contact.info"); const tInfo = useTranslations("home.contact.info");
const [cmsDoc, setCmsDoc] = useState<JSONContent | null>(null); const [cmsHtml, setCmsHtml] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
(async () => { (async () => {
@@ -25,14 +24,14 @@ const Contact = () => {
); );
const data = await res.json(); const data = await res.json();
// Only use CMS content if it exists for the active locale. // Only use CMS content if it exists for the active locale.
if (data?.content?.content && data?.content?.locale === locale) { if (data?.content?.html && data?.content?.locale === locale) {
setCmsDoc(data.content.content as JSONContent); setCmsHtml(data.content.html as string);
} else { } else {
setCmsDoc(null); setCmsHtml(null);
} }
} catch { } catch {
// ignore; fallback to static // ignore; fallback to static
setCmsDoc(null); setCmsHtml(null);
} }
})(); })();
}, [locale]); }, [locale]);
@@ -169,8 +168,8 @@ const Contact = () => {
<h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-7xl font-black text-stone-900 dark:text-stone-50 tracking-tighter uppercase mb-4 sm:mb-6 md:mb-8"> <h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-7xl font-black text-stone-900 dark:text-stone-50 tracking-tighter uppercase mb-4 sm:mb-6 md:mb-8">
{t("title")}<span className="text-emerald-600 dark:text-emerald-400">.</span> {t("title")}<span className="text-emerald-600 dark:text-emerald-400">.</span>
</h2> </h2>
{cmsDoc ? ( {cmsHtml ? (
<RichTextClient doc={cmsDoc} className="prose prose-stone dark:prose-invert max-w-none text-base sm:text-lg md:text-xl lg:text-2xl font-light leading-relaxed text-stone-600 dark:text-stone-400" /> <RichTextClient html={cmsHtml} className="prose prose-stone dark:prose-invert max-w-none text-base sm:text-lg md:text-xl lg:text-2xl font-light leading-relaxed text-stone-600 dark:text-stone-400" />
) : ( ) : (
<p className="text-base sm:text-lg md:text-xl lg:text-2xl font-light leading-relaxed text-stone-600 dark:text-stone-400"> <p className="text-base sm:text-lg md:text-xl lg:text-2xl font-light leading-relaxed text-stone-600 dark:text-stone-400">
{t("subtitle")} {t("subtitle")}

View File

@@ -1,22 +1,19 @@
"use client"; "use client";
import React, { useMemo } from "react"; import React from "react";
import type { JSONContent } from "@tiptap/react";
import { richTextToSafeHtml } from "@/lib/richtext";
// Accepts pre-sanitized HTML string (converted server-side via richTextToSafeHtml).
// This keeps TipTap/ProseMirror out of the client bundle entirely.
export default function RichTextClient({ export default function RichTextClient({
doc, html,
className, className,
}: { }: {
doc: JSONContent; html: string;
className?: string; className?: string;
}) { }) {
const html = useMemo(() => richTextToSafeHtml(doc), [doc]);
return ( return (
<div <div
className={className} className={className}
// HTML is sanitized in `richTextToSafeHtml`
dangerouslySetInnerHTML={{ __html: html }} dangerouslySetInnerHTML={{ __html: html }}
/> />
); );

View File

@@ -8,13 +8,12 @@ import Footer from "../components/Footer";
import Link from "next/link"; import Link from "next/link";
import { useLocale, useTranslations } from "next-intl"; import { useLocale, useTranslations } from "next-intl";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import type { JSONContent } from "@tiptap/react";
import RichTextClient from "../components/RichTextClient"; import RichTextClient from "../components/RichTextClient";
export default function LegalNotice() { export default function LegalNotice() {
const locale = useLocale(); const locale = useLocale();
const t = useTranslations("common"); const t = useTranslations("common");
const [cmsDoc, setCmsDoc] = useState<JSONContent | null>(null); const [cmsHtml, setCmsHtml] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
(async () => { (async () => {
@@ -23,8 +22,8 @@ export default function LegalNotice() {
`/api/content/page?key=${encodeURIComponent("legal-notice")}&locale=${encodeURIComponent(locale)}`, `/api/content/page?key=${encodeURIComponent("legal-notice")}&locale=${encodeURIComponent(locale)}`,
); );
const data = await res.json(); const data = await res.json();
if (data?.content?.content && data?.content?.locale === locale) { if (data?.content?.html && data?.content?.locale === locale) {
setCmsDoc(data.content.content as JSONContent); setCmsHtml(data.content.html as string);
} }
} catch {} } catch {}
})(); })();
@@ -64,9 +63,9 @@ export default function LegalNotice() {
transition={{ delay: 0.1 }} transition={{ delay: 0.1 }}
className="lg:col-span-8 bg-white dark:bg-stone-900 rounded-[3rem] p-10 md:p-16 border border-stone-200/60 dark:border-stone-800/60 shadow-sm" className="lg:col-span-8 bg-white dark:bg-stone-900 rounded-[3rem] p-10 md:p-16 border border-stone-200/60 dark:border-stone-800/60 shadow-sm"
> >
{cmsDoc ? ( {cmsHtml ? (
<div className="prose prose-stone dark:prose-invert max-w-none text-lg md:text-xl font-light leading-relaxed"> <div className="prose prose-stone dark:prose-invert max-w-none text-lg md:text-xl font-light leading-relaxed">
<RichTextClient doc={cmsDoc} /> <RichTextClient html={cmsHtml} />
</div> </div>
) : ( ) : (
<div className="space-y-16"> <div className="space-y-16">

View File

@@ -8,13 +8,12 @@ import Footer from "../components/Footer";
import Link from "next/link"; import Link from "next/link";
import { useLocale, useTranslations } from "next-intl"; import { useLocale, useTranslations } from "next-intl";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import type { JSONContent } from "@tiptap/react";
import RichTextClient from "../components/RichTextClient"; import RichTextClient from "../components/RichTextClient";
export default function PrivacyPolicy() { export default function PrivacyPolicy() {
const locale = useLocale(); const locale = useLocale();
const t = useTranslations("common"); const t = useTranslations("common");
const [cmsDoc, setCmsDoc] = useState<JSONContent | null>(null); const [cmsHtml, setCmsHtml] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
(async () => { (async () => {
@@ -23,8 +22,8 @@ export default function PrivacyPolicy() {
`/api/content/page?key=${encodeURIComponent("privacy-policy")}&locale=${encodeURIComponent(locale)}`, `/api/content/page?key=${encodeURIComponent("privacy-policy")}&locale=${encodeURIComponent(locale)}`,
); );
const data = await res.json(); const data = await res.json();
if (data?.content?.content && data?.content?.locale === locale) { if (data?.content?.html && data?.content?.locale === locale) {
setCmsDoc(data.content.content as JSONContent); setCmsHtml(data.content.html as string);
} }
} catch {} } catch {}
})(); })();
@@ -64,9 +63,9 @@ export default function PrivacyPolicy() {
transition={{ delay: 0.1 }} transition={{ delay: 0.1 }}
className="lg:col-span-8 bg-white dark:bg-stone-900 rounded-[3rem] p-10 md:p-16 border border-stone-200/60 dark:border-stone-800/60 shadow-sm" className="lg:col-span-8 bg-white dark:bg-stone-900 rounded-[3rem] p-10 md:p-16 border border-stone-200/60 dark:border-stone-800/60 shadow-sm"
> >
{cmsDoc ? ( {cmsHtml ? (
<div className="prose prose-stone dark:prose-invert max-w-none text-lg md:text-xl font-light leading-relaxed"> <div className="prose prose-stone dark:prose-invert max-w-none text-lg md:text-xl font-light leading-relaxed">
<RichTextClient doc={cmsDoc} /> <RichTextClient html={cmsHtml} />
</div> </div>
) : ( ) : (
<div className="space-y-16"> <div className="space-y-16">