Frontend Performance 8 min read

From 77 to 100: A Practical Guide to Lighthouse Score Optimization in Next.js

Hoang Dang Tan Phat (Kane)

Hoang Dang Tan Phat (Kane)

Feb 20, 2026

We ran Lighthouse on our Next.js e-commerce site and got: Performance 85, Accessibility 100, Best Practices 77, SEO 91. After a focused optimization session, we reached 98/100/96/100. Here’s every fix we made and why it worked.

Starting Point

CategoryScore
Performance85
Accessibility100
Best Practices77
SEO91

The site is a Next.js 15 App Router project with Prisma, Stripe, and server components. Deployed on Vercel.

Best Practices: 77 → 96

The biggest gains came from fixing Content Security Policy headers.

Remove unsafe-eval from CSP

Our next.config.ts had an overly permissive CSP:

// Before - next.config.ts
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://js.stripe.com",
"img-src 'self' data: blob: https: http:",

unsafe-eval allows eval() and similar functions, which is a major security risk. Next.js doesn’t need it for production builds. And http: in img-src allows mixed content.

// After
"script-src 'self' 'unsafe-inline' https://js.stripe.com",
"img-src 'self' data: blob: https:",

Why unsafe-inline stays: Next.js injects inline scripts for hydration. Removing it breaks the app. This is why Best Practices caps at 96 for most Next.js sites — Lighthouse flags unsafe-inline but it’s unavoidable.

Impact: +19 points

This single change was responsible for most of the Best Practices improvement.

SEO: 91 → 100

Four fixes brought SEO to a perfect score.

1. Add robots.ts

Next.js App Router supports a robots.ts file that generates robots.txt at build time:

// src/app/robots.ts
import type { MetadataRoute } from "next"

export default function robots(): MetadataRoute.Robots {
  const baseUrl = (process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000").trim()

  return {
    rules: [
      {
        userAgent: "*",
        allow: "/",
        disallow: ["/admin/", "/api/", "/checkout/", "/account/"],
      },
    ],
    sitemap: [baseUrl, "sitemap.xml"].join("/"),
  }
}

Gotcha: If your NEXT_PUBLIC_APP_URL environment variable has a trailing newline, the sitemap URL gets split across two lines, which Lighthouse flags as invalid syntax. Use .trim() and join() instead of template literals to be safe.

2. Add Dynamic sitemap.ts

// src/app/sitemap.ts
import type { MetadataRoute } from "next"
import { prisma } from "@/lib/prisma"

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"

  const products = await prisma.product.findMany({
    where: { isActive: true },
    select: { slug: true, updatedAt: true },
  })

  const categories = await prisma.category.findMany({
    select: { slug: true },
  })

  return [
    { url: baseUrl, lastModified: new Date(), changeFrequency: "daily", priority: 1 },
    { url: `\${baseUrl}/products`, changeFrequency: "daily", priority: 0.9 },
    ...products.map((p) => ({
      url: `\${baseUrl}/products/\${p.slug}`,
      lastModified: p.updatedAt,
      changeFrequency: "weekly" as const,
      priority: 0.8,
    })),
    ...categories.map((c) => ({
      url: `\${baseUrl}/categories/\${c.slug}`,
      changeFrequency: "weekly" as const,
      priority: 0.7,
    })),
  ]
}

3. Comprehensive Metadata in Root Layout

// src/app/layout.tsx
export const metadata: Metadata = {
  title: {
    default: "Glow - Beauty & Skincare",
    template: "%s | Glow",  // Child pages just set "Products", not "Products | Glow"
  },
  description: "...",
  metadataBase: new URL(process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"),
  openGraph: {
    type: "website",
    siteName: "Glow",
    title: "Glow - Beauty & Skincare",
    description: "...",
  },
  twitter: {
    card: "summary_large_image",
    title: "Glow - Beauty & Skincare",
    description: "...",
  },
  robots: { index: true, follow: true },
}

Common mistake: If you use a title template like "%s | Glow" in the root layout, don’t also append ”| Glow” in child pages. We had title set to the product name plus ”| Glow” in generateMetadata, which produced “Coconut Body Oil | Glow | Glow”. Just use title: product.name and let the template handle it.

4. Add Metadata to Client Component Pages

Client components ("use client") can’t export metadata. The fix is to add a layout.tsx wrapper:

// src/app/(shop)/cart/layout.tsx
import type { Metadata } from "next"

export const metadata: Metadata = {
  title: "Cart",
  description: "Review your shopping cart and proceed to checkout.",
}

export default function CartLayout({ children }: { children: React.ReactNode }) {
  return children
}

We added these for Cart, Login, and Register pages.

5. Block Admin Pages from Crawlers

// src/app/admin/layout.tsx
export const metadata: Metadata = {
  robots: { index: false, follow: false },
}

Performance: 85 → 98

Replace force-dynamic with ISR

Our homepage had:

export const dynamic = "force-dynamic"

This forced server-side rendering on every request. The homepage data (products, categories) doesn’t change frequently, so ISR with a 60-second revalidation is better:

export const revalidate = 60

Result on production:

  • TTFB dropped from server-render time to 43ms (cached response)
  • LCP dropped to 381ms

When to Use force-dynamic vs revalidate

ScenarioUse
Data changes every request (user-specific)force-dynamic
Data changes occasionally (product catalog)revalidate = 60 (or higher)
Data rarely changes (about page)Static (default)

Accessibility: Heading Order

Lighthouse flagged heading order: our page went h1 (hero) → h3 (brand values), skipping h2. Screen readers use heading hierarchy for navigation, so this matters.

- <h3 className="font-serif text-lg font-semibold">{value.title}</h3>
+ <h2 className="font-serif text-lg font-semibold">{value.title}</h2>

The visual size is controlled by CSS classes, not the heading level. Use the semantically correct level and style it however you want.

Final Results

CategoryBeforeAfterChange
Performance8598+13
Accessibility100100
Best Practices7796+19
SEO91100+9

Quick Checklist

For any Next.js project, check these:

  • CSP headers: no unsafe-eval, no http: in img-src
  • robots.ts with proper sitemap URL
  • sitemap.ts with all public routes
  • Root layout has OpenGraph, Twitter, and robots metadata
  • Title template in root + simple titles in children (no duplication)
  • Client component pages have metadata via layout.tsx
  • Admin/private routes have noindex, nofollow
  • No force-dynamic where ISR would work
  • Heading hierarchy is sequential (h1 → h2 → h3)
  • All images use next/image with sizes attribute

The 96 Best Practices Ceiling

Most Next.js apps will cap at 96 for Best Practices because unsafe-inline in script-src is required for hydration. Next.js 15 has experimental support for CSP nonces, but it requires middleware configuration and isn’t straightforward with all deployment targets. For most projects, 96 is an excellent score.

nextjs lighthouse seo performance web-vitals csp
Hoang Dang Tan Phat (Kane)

Hoang Dang Tan Phat (Kane)

Full-stack developer with 8+ years experience. Building scalable systems with Go, TypeScript, and React.