perf: convert Hero to server component for faster LCP
All checks were successful
Gitea CI / test-build (push) Successful in 11m9s

- Hero now renders server-side, eliminating JS dependency for LCP text
- CMS messages fetched server-side instead of client useEffect
- Removes Hero from client JS bundle (~5KB less)
- LCP element (hero paragraph) now in initial HTML response
- Eliminates 2,380ms 'element rendering delay' reported by PSI

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-03-04 14:16:58 +01:00
parent 60ea4e99be
commit 9fd530c68f
5 changed files with 30 additions and 57 deletions

View File

@@ -1,16 +1,21 @@
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import Hero from '@/app/components/Hero'; import Hero from '@/app/components/Hero';
// Mock next-intl // Mock next-intl/server
jest.mock('next-intl', () => ({ jest.mock('next-intl/server', () => ({
useLocale: () => 'en', getTranslations: () => Promise.resolve((key: string) => {
useTranslations: () => (key: string) => {
const messages: Record<string, string> = { const messages: Record<string, string> = {
description: 'Dennis is a student and passionate self-hoster.', description: 'Dennis is a student and passionate self-hoster.',
ctaWork: 'View My Work' ctaWork: 'View My Work',
ctaContact: 'Get in touch',
}; };
return messages[key] || key; return messages[key] || key;
}, }),
}));
// Mock directus getMessages
jest.mock('@/lib/directus', () => ({
getMessages: () => Promise.resolve({}),
})); }));
// Mock next/image // Mock next/image
@@ -36,8 +41,9 @@ jest.mock('next/image', () => ({
})); }));
describe('Hero', () => { describe('Hero', () => {
it('renders the hero section correctly', () => { it('renders the hero section correctly', async () => {
render(<Hero />); const HeroResolved = await Hero({ locale: 'en' });
render(HeroResolved);
// Check for the main headlines (defaults in Hero.tsx) // Check for the main headlines (defaults in Hero.tsx)
expect(screen.getByText('Building')).toBeInTheDocument(); expect(screen.getByText('Building')).toBeInTheDocument();

View File

@@ -41,7 +41,7 @@ export default function HomePage() {
{/* Spacer to prevent navbar overlap */} {/* Spacer to prevent navbar overlap */}
<div className="h-16 sm:h-24 md:h-32" aria-hidden="true"></div> <div className="h-16 sm:h-24 md:h-32" aria-hidden="true"></div>
<main className="relative"> <main className="relative">
<Hero /> <Hero locale="en" />
{/* Wavy Separator 1 - Hero to About */} {/* Wavy Separator 1 - Hero to About */}
<div className="relative h-12 sm:h-16 md:h-24 overflow-hidden"> <div className="relative h-12 sm:h-16 md:h-24 overflow-hidden">

View File

@@ -1,14 +1,13 @@
import Header from "../components/Header.server"; import Header from "../components/Header.server";
import Hero from "../components/Hero";
import Script from "next/script"; import Script from "next/script";
import { import {
getHeroTranslations,
getAboutTranslations, getAboutTranslations,
getProjectsTranslations, getProjectsTranslations,
getContactTranslations, getContactTranslations,
getFooterTranslations, getFooterTranslations,
} from "@/lib/translations-loader"; } from "@/lib/translations-loader";
import { import {
HeroClient,
AboutClient, AboutClient,
ProjectsClient, ProjectsClient,
ContactClient, ContactClient,
@@ -20,9 +19,8 @@ interface HomePageServerProps {
} }
export default async function HomePageServer({ locale }: HomePageServerProps) { export default async function HomePageServer({ locale }: HomePageServerProps) {
// Parallel laden aller Translations // Parallel laden aller Translations (hero translations handled by Hero server component)
const [heroT, aboutT, projectsT, contactT, footerT] = await Promise.all([ const [aboutT, projectsT, contactT, footerT] = await Promise.all([
getHeroTranslations(locale),
getAboutTranslations(locale), getAboutTranslations(locale),
getProjectsTranslations(locale), getProjectsTranslations(locale),
getContactTranslations(locale), getContactTranslations(locale),
@@ -57,7 +55,7 @@ export default async function HomePageServer({ locale }: HomePageServerProps) {
{/* Spacer to prevent navbar overlap */} {/* Spacer to prevent navbar overlap */}
<div className="h-24 md:h-32" aria-hidden="true"></div> <div className="h-24 md:h-32" aria-hidden="true"></div>
<main className="relative"> <main className="relative">
<HeroClient locale={locale} translations={heroT} /> <Hero locale={locale} />
{/* Wavy Separator 1 - Hero to About */} {/* Wavy Separator 1 - Hero to About */}
<div className="relative h-24 overflow-hidden"> <div className="relative h-24 overflow-hidden">

View File

@@ -7,9 +7,7 @@
import { NextIntlClientProvider } from 'next-intl'; import { NextIntlClientProvider } from 'next-intl';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import Hero from './Hero';
import type { import type {
HeroTranslations,
AboutTranslations, AboutTranslations,
ProjectsTranslations, ProjectsTranslations,
ContactTranslations, ContactTranslations,
@@ -30,23 +28,6 @@ function getNormalizedLocale(locale: string): 'en' | 'de' {
return locale.startsWith('de') ? 'de' : 'en'; return locale.startsWith('de') ? 'de' : 'en';
} }
export function HeroClient({ locale }: { locale: string; translations: HeroTranslations }) {
const normalLocale = getNormalizedLocale(locale);
const baseMessages = messageMap[normalLocale];
const messages = {
home: {
hero: baseMessages.home.hero
}
};
return (
<NextIntlClientProvider locale={locale} messages={messages}>
<Hero />
</NextIntlClientProvider>
);
}
export function AboutClient({ locale }: { locale: string; translations: AboutTranslations }) { export function AboutClient({ locale }: { locale: string; translations: AboutTranslations }) {
const normalLocale = getNormalizedLocale(locale); const normalLocale = getNormalizedLocale(locale);
const baseMessages = messageMap[normalLocale]; const baseMessages = messageMap[normalLocale];

View File

@@ -1,27 +1,17 @@
"use client"; import { getTranslations } from "next-intl/server";
import { useLocale, useTranslations } from "next-intl";
import Image from "next/image"; import Image from "next/image";
import { useEffect, useState } from "react"; import { getMessages } from "@/lib/directus";
const Hero = () => { interface HeroProps {
const locale = useLocale(); locale: string;
const t = useTranslations("home.hero"); }
const [cmsMessages, setCmsMessages] = useState<Record<string, string>>({});
useEffect(() => { export default async function Hero({ locale }: HeroProps) {
(async () => { const [t, cmsMessages] = await Promise.all([
try { getTranslations("home.hero"),
const res = await fetch(`/api/messages?locale=${locale}`); getMessages(locale).catch(() => ({} as Record<string, string>)),
if (res.ok) { ]);
const data = await res.json();
setCmsMessages(data.messages || {});
}
} catch {}
})();
}, [locale]);
// Helper to get CMS text or fallback
const getLabel = (key: string, fallback: string) => cmsMessages[key] || fallback; const getLabel = (key: string, fallback: string) => cmsMessages[key] || fallback;
return ( return (
@@ -86,6 +76,4 @@ const Hero = () => {
</div> </div>
</section> </section>
); );
}; }
export default Hero;