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
| Category | Score |
|---|---|
| Performance | 85 |
| Accessibility | 100 |
| Best Practices | 77 |
| SEO | 91 |
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
| Scenario | Use |
|---|---|
| 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
| Category | Before | After | Change |
|---|---|---|---|
| Performance | 85 | 98 | +13 |
| Accessibility | 100 | 100 | — |
| Best Practices | 77 | 96 | +19 |
| SEO | 91 | 100 | +9 |
Quick Checklist
For any Next.js project, check these:
- CSP headers: no
unsafe-eval, nohttp:inimg-src -
robots.tswith proper sitemap URL -
sitemap.tswith 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-dynamicwhere ISR would work - Heading hierarchy is sequential (h1 → h2 → h3)
- All images use
next/imagewithsizesattribute
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.