The ultimate guide for mastering Next.js for Lighthouse and SEO

Lokman Musliu Founder and CEO of Lucky Media
Lokman Musliu

November 5, 2025 · 11 min read

Mastering Next.js for Lighthouse and SEO

Next.js, with its powerful App Router, is the go-to framework for building fast, SEO-friendly React applications. It offers incredible features like Server Components, advanced routing, and built-in optimizations. However, without careful optimization, a Next.js site might struggle to achieve a perfect Lighthouse score and better SEO.

Optimizing your Next.js site is super important. A slow site frustrates users, leading to higher bounce rates and lower conversions, while an invisible site to search engines loses leads and opportunities, affecting ROI. The goal is to create exceptional Next.js App Router sites that deliver fast experiences and excel in search results.

Why Server Components Matter

Optimizing your Next.js App Router site

Using server components for optimal data fetching

Server Components (RSCs) fetch data and render UI directly on the server, sending only the necessary HTML and CSS to the client. This reduces the JavaScript bundle size, leading to faster loading times.

Instead of fetching data on the client-side (which often leads to loading spinners), RSCs fetch data before the page even starts rendering in the browser. This means the user sees a fully loaded page much quicker.

Parallel data fetching

One common anti-pattern is sequential data fetching, where one data request waits for another to complete. With Server Components, you can fetch data in parallel using Promise.all, significantly speeding up your page load.

Let's say you have a product page that needs both product details and related recommendations. Instead of fetching them one after another, do it concurrently:

// app/product/[id]/page.tsx

async function getProduct(id: string) {
  const res = await fetch(`https://api.example.com/products/${id}`, {
    next: { revalidate: 3600 } // Cache data for 1 hour
  });
  if (!res.ok) throw new Error('Failed to fetch product');
  return res.json();
}

async function getRecommendations(category: string) {
  const res = await fetch(`https://api.example.com/recommendations/${category}`, {
    next: { revalidate: 3600 } // Cache data for 1 hour
  });
  if (!res.ok) throw new Error('Failed to fetch recommendations');
  return res.json();
}

export default async function ProductPage({ params }: { params: { id: string } }) {
  // Fetch product first to get its category for recommendations
  const product = await getProduct(params.id);

  // Now fetch recommendations in parallel with other potential data needs
  const [recommendations] = await Promise.all([
    getRecommendations(product.category),
    // Add other parallel fetches here if needed
  ]);

  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <h2>Related Products</h2>
      <ul>
        {recommendations.map((rec: any) => (
          <li key={rec.id}>{rec.name}</li>
        ))}
      </ul>
    </div>
  );
}

Notice how ProductPage is an async component. This is the beauty of Server Components: you can await your data fetches directly within the component, and Next.js handles the rendering on the server. This eliminates client-side loading states so your users see content faster.

Partial Prerendering for dynamic content

What if parts of your page are static, but others are highly dynamic (like a user-specific dashboard widget)? Next.js 14.1+ introduced Partial Prerendering, a powerful feature that lets you deliver static content instantly while streaming dynamic content as it becomes ready.

Using Suspense boundaries

Wrap the dynamic parts of your component tree with Suspense to define a fallback (like a loading spinner) that will be shown while the dynamic content loads.

// app/dashboard/page.tsx
import { Suspense } from 'react';
import { UserActivity } from './user-activity'; // This is a Client Component fetching dynamic user data
import { StaticDashboardMetrics } from './static-metrics'; // This is a Server Component with static metrics

export default function Dashboard() {
  return (
    <div className="dashboard">
      {/* Static content: prerendered instantly */}
      <StaticDashboardMetrics />

      {/* Dynamic content: streamed in as it loads */}
      <Suspense fallback={<p>Loading user activity...</p>}>
        <UserActivity />
      </Suspense>

      {/* You can have multiple Suspense boundaries */}
      <Suspense fallback={<p>Loading recent orders...</p>}>
        <RecentOrders />
      </Suspense>
    </div>
  );
}

In this setup, StaticDashboardMetrics (a Server Component) will be part of the initial, fast HTML response. UserActivity and RecentOrders (which might be Client Components fetching personalized data) will show their respective fallbacks (Loading user activity..., Loading recent orders...) until their data is ready, at which point they replace the fallbacks. This keeps your users engaged and reduces the perceived load time.

Nextjs logo fast development

Image Optimization with next/image

Images are often the biggest factor in page load times. The next/image component offers automatic optimization out of the box. It handles lazy loading, responsive sizing, and even converts images to modern formats like WebP or AVIF if the browser supports them.

Advanced next/image usage

import Image from 'next/image';

export default function ProductGallery({ images }: { images: any[] }) {
  return (
    <div className="gallery">
      {images.map((image) => (
        <Image
          key={image.id}
          src={image.url}
          alt={image.altText || 'Product image'}
          width={600} // Original width of the image
          height={400} // Original height of the image
          placeholder="blur" // Show a blurred version while loading
          blurDataURL={image.blurDataUrl} // Base64 encoded low-res image
          sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw" // Define image sizes for different viewports
          priority={image.isPrimary} // Prioritize loading for LCP images
        />
      ))}
    </div>
  );
}

Key attributes here:

  • width and height: Always provide these to prevent Cumulative Layout Shift (CLS). Next.js uses them to reserve space.

  • placeholder="blur" and blurDataURL: Provides a low-resolution placeholder that blurs into the full image, improving perceived performance.

  • sizes Crucial for responsive images. It tells the browser how wide the image will be at different viewport sizes, allowing Next.js to serve the most appropriate image size.

  • priority Set this to true for images that are part of your Largest Contentful Paint (LCP). This tells Next.js to preload them for the fastest possible display.

Remember to host your images on a CDN for even faster delivery, and ensure your next.config.js is configured to allow image optimization from your image domains.

Advanced code splitting

Code splitting breaks your JavaScript into smaller chunks loaded on demand, improving load times. Next.js handles much of this automatically, but you can take it further, especially with the App Router. Use dynamic imports and route-based splitting for better performance.

Dynamic imports with Suspense and Route Groups

Next.js handles dynamic imports (next/dynamic) and route-based code splitting by default. But you can combine this with Suspense for a better user experience and use App Router's route groups for logical code organization.

// app/dashboard/layout.tsx (Example of a route group layout)
// This layout will apply to all routes within the (dashboard) group,
// potentially leading to a separate JS chunk for dashboard-related code.

export default function DashboardLayout({ children }: { children: React.ReactNode }) {
  return (
    <div className="dashboard-layout">
      <nav>Dashboard Nav</nav>
      {children}
    </div>
  );
}

// app/analytics/page.tsx (Example of dynamic import with Suspense)
'use client'; // This page needs client-side interactivity

import dynamic from 'next/dynamic';
import { Suspense } from 'react';

// Dynamically import a large component that's not needed immediately
const AnalyticsChart = dynamic(() => import('@/components/AnalyticsChart'), {
  loading: () => <p>Loading chart...</p>, // Fallback while the component loads
  ssr: false, // Optional: if the component relies heavily on browser APIs, disable SSR
});

export default function AnalyticsPage() {
  return (
    <div>
      <h1>Your Analytics Overview</h1>
      {/* The chart component will only load its JS when this part of the UI is rendered */}
      <Suspense fallback={<p>Preparing your analytics data...</p>}>
        <AnalyticsChart />
      </Suspense>
    </div>
  );
}

Let's explain what we have here:

  • Route Groups (e.g., (dashboard)) allow you to logically group routes without affecting the URL path. This can help Next.js create separate bundles for different sections of your application (e.g., marketing vs. authenticated user areas), making sure that users only download the code relevant to their current context.

  • dynamic(() => import(...)) makes sure that the AnalyticsChart component's JavaScript bundle is loaded only when it's actually needed. The loading option provides a fallback, and Suspense provides a boundary for the loading state, preventing the entire page from blocking.

This granular control over code loading significantly reduces initial page weight, improving LCP and overall perceived performance.

Streaming Server Rendering for improved user experience

We touched on this with Partial Prerendering, but it's worth emphasizing. Streaming allows you to send parts of your HTML to the browser as they become ready, rather than waiting for the entire server-rendered page to be complete. This means the browser can start rendering the header, navigation, and other static elements while dynamic data-fetching components are still loading on the server.

Using Streaming with Suspense

This is tied to Suspense boundaries in Server Components. When you use Suspense in a Server Component, Next.js automatically streams the HTML for the content outside the Suspense boundary first, and then streams in the HTML for the content inside the boundary once it's ready.

// app/products/[id]/page.tsx
import { Suspense } from 'react';
import ProductHeader from './ProductHeader'; // Server Component
import ProductDetails from './ProductDetails'; // Server Component, might fetch data
import RelatedProducts from './RelatedProducts'; // Server Component, might fetch data
import ReviewSection from './ReviewSection'; // Client Component, might fetch data

export default function ProductPage({ params }: { params: { id: string } }) {
  return (
    <main>
      <ProductHeader productId={params.id} />

      <Suspense fallback={<p>Loading product details...</p>}>
        <ProductDetails productId={params.id} />
      </Suspense>

      <Suspense fallback={<p>Finding related products...</p>}>
        <RelatedProducts productId={params.id} />
      </Suspense>

      {/* This could be a Client Component, loaded dynamically */}
      <Suspense fallback={<p>Loading reviews...</p>}>
        <ReviewSection productId={params.id} />
      </Suspense>
    </main>
  );
}

In this structure, the ProductHeader will render immediately. The ProductDetails, RelatedProducts, and ReviewSection components will show their respective loading fallbacks while their data is being fetched and rendered on the server. As each section becomes ready, its HTML is streamed to the client, allowing the browser to progressively render the page. This significantly improves perceived loading speed and user satisfaction.

Third-party script optimization

Marketing sites often rely heavily on third-party scripts: analytics (e.g., Google Analytics), ad scripts, chat widgets, A/B testing tools, and more. These scripts are known for being very slow, stopping pages from loading properly and making your site run slowly. next/script is your secret weapon here.

The problem

Traditional <script> tags block the parsing of your HTML, delaying page rendering. Many third-party scripts are also not optimized for performance.

The solution

next/script allows you to strategically load these scripts, giving you control over when and how they execute.

next/script with different strategies

// app/layout.tsx (or a specific page/component if script is page-specific)

import { Html, Head, Main, NextScript } from 'next/document';
import Script from 'next/script';

export default function Document() {
  return (
    <Html lang="en">
      <Head />
      <body>
        <Main />
        <NextScript />

        {/* Google Analytics - loads as soon as possible, but doesn't block HTML parsing */}
        <Script
          src="https://www.googletagmanager.com/gtag/js?id=GA_MEASUREMENT_ID"
          strategy="afterInteractive" // Loads after the page is interactive
        />
        <Script id="google-analytics" strategy="afterInteractive">
          {`
            window.dataLayer = window.dataLayer || [];
            function gtag(){dataLayer.push(arguments);}
            gtag('js', new Date());
            gtag('config', 'GA_MEASUREMENT_ID');
          `}
        </Script>

        {/* Chat Widget - loads lazily after the page is mostly idle */}
        <Script
          src="https://cdn.example.com/chat-widget.js"
          strategy="lazyOnload" // Loads during idle time
        />

        {/* A/B Testing Script - loads before any hydration occurs */}
        <Script
          src="https://cdn.example.com/ab-test.js"
          strategy="beforeInteractive" // Loads before React hydrates the page
        />
      </body>
    </Html>
  );
}

Key strategy options:

  • beforeInteractive loads before any Next.js client-side JavaScript is executed. Use for scripts that need to run very early, like A/B testing or consent management.

  • afterInteractive (default) loads after the page is interactive. Good for analytics, tag managers, and most third-party scripts.

  • lazyOnload loads during the browser's idle time. Best for scripts that aren't critical to the initial user experience, like chat widgets or social media embeds.

By carefully choosing the loading strategy for each third-party script, you can prevent them from negatively impacting your Lighthouse scores, especially LCP and FID.

Next js is perfect for Dynamic, dashboards, SEO

SEO recommendations

Now that your Next.js site is fast, search engines need to recognize it and appreciate it. Technical SEO is important for marketing sites so your content reaches its intended audience.

Metadata

Metadata is the information about your page that isn't directly visible on the page itself but is crucial for search engines and social media platforms.

Accurate and descriptive metadata helps search engines understand your content, leading to better rankings and more relevant search results. It also controls how your links appear when shared on social media (Open Graph, Twitter Cards), making them more engaging.

layout.js Metadata setup

Next.js provides a powerful Metadata API. You can define metadata statically using a metadata object or dynamically using a generate metadata function in your layout.js or page.js files.

// app/layout.tsx

import type { Metadata } from 'next';

export const metadata: Metadata = {
  title: {
    default: 'Your Marketing Site - High Performance & SEO',
    template: '%s | Your Marketing Site',
  },
  description: 'Discover how to achieve perfect Lighthouse scores and dominate SEO with Next.js App Router for your marketing website. Learn performance optimizations and technical SEO best practices.',
  keywords: ['Next.js', 'App Router', 'Lighthouse', 'SEO', 'Performance', 'Marketing Site', 'Web Development'],
  authors: [{ name: 'Lucky Media' }],
  creator: 'Lucky Media',
  publisher: 'Your Company Name',
  openGraph: {
    title: 'Next.js Lighthouse & SEO Guide',
    description: 'Optimize your Next.js App Router marketing site for peak performance and search engine visibility.',
    url: 'https://www.yourdomain.com',
    siteName: 'Your Marketing Site',
    images: [
      {
        url: 'https://www.yourdomain.com/og-image.jpg', // Absolute URL for Open Graph image
        width: 1200,
        height: 630,
        alt: 'Next.js Lighthouse SEO Optimization',
      },
    ],
    locale: 'en_US',
    type: 'website',
  },
  twitter: {
    card: 'summary_large_image',
    title: 'Next.js Lighthouse & SEO Guide',
    description: 'Optimize your Next.js App Router marketing site for peak performance and search engine visibility.',
    creator: '@yourtwitterhandle',
    images: ['https://www.yourdomain.com/twitter-image.jpg'],
  },
  // Add more meta tags as needed, e.g., robots, canonical
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  );
}

This setup provides default metadata for your entire application. You can then override or extend this metadata on individual pages or nested layouts, allowing for highly specific and optimized titles, descriptions, and Open Graph images for every piece of content.

Canonical URLs: Preventing duplicate content headaches

Duplicate content is an SEO nightmare. It confuses search engines and can lead to ranking penalties and lose rank. This often happens with URL parameters (e.g., yourdomain.com/product?color=red and yourdomain.com/product), pagination, or content accessible via multiple paths. The solution? Canonical URLs.

The Concept: A canonical URL tells search engines which version of a page to index. It’s like telling the librarian, "Hey, these two books are essentially the same, but this one is the original and should be cataloged."

Implementing canonical tags

You implement canonical URLs using a <link rel="canonical" href="..."/> tag in the <head> of your HTML. In Next.js App Router, you can set this in your layout.js or page.js files, often dynamically.

// app/blog/[slug]/page.tsx (Example within a dynamic page component)

import type { Metadata } from 'next';

interface BlogPostProps {
  params: { slug: string };
}

// Function to fetch blog post data (replace with your actual data fetching logic)
async function getBlogPost(slug: string) {
  // Simulate fetching data
  const posts = [
    { slug: 'perfect-lighthouse-nextjs', title: 'Perfect Lighthouse with Next.js', content: '...', canonicalPath: '/blog/perfect-lighthouse-nextjs' },
    { slug: 'seo-tips-marketing-sites', title: 'SEO Tips for Marketing Sites', content: '...', canonicalPath: '/blog/seo-tips-marketing-sites' },
  ];
  return posts.find(post => post.slug === slug);
}

// Generate metadata dynamically for each blog post
export async function generateMetadata({ params }: BlogPostProps): Promise<Metadata> {
  const post = await getBlogPost(params.slug);

  if (!post) {
    return { title: 'Not Found' };
  }

  const canonicalUrl = `https://www.yourdomain.com${post.canonicalPath}`;

  return {
    title: post.title,
    description: `Read about ${post.title} and more.`, // More descriptive description
    alternates: {
      canonical: canonicalUrl,
    },
    // You can also add Open Graph, Twitter cards specific to this post here
  };
}

export default async function BlogPostPage({ params }: BlogPostProps) {
  const post = await getBlogPost(params.slug);

  if (!post) {
    return <div>Post not found</div>;
  }

  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
    </article>
  );
}

By setting the alternates.canonical property in your generateMetadata function, Next.js will automatically add the correct canonical link tag to the page's <head>, ensuring search engines understand the authoritative version of your content.

URL structure optimization

URL structure optimization

Your URLs are not just addresses; they're signals to both users and search engines about what your page is about. A clean, semantic URL structure improves user experience, makes your site easier to navigate, and can even contribute to better search rankings.

Best Practices for URLs:

  • Use hyphens (-): Separate words with hyphens (e.g., my-awesome-product) instead of underscores (_) or spaces. Search engines treat hyphens as word separators.

  • Lowercase letters: Always use lowercase. This avoids potential duplicate content issues (e.g., /Product vs. /product).

  • Include keywords: Naturally incorporate relevant keywords into your URLs. This provides another hint to search engines about your page's topic.

  • Keep it concise: Shorter, descriptive URLs are generally preferred. Avoid unnecessary parameters or long strings of numbers.

  • Use Next.js App Router's folder structure: The App Router's file-based routing naturally encourages clean URLs. For example, app/blog/[slug]/page.tsx automatically creates URLs like /blog/your-blog-post-title.

Example:

  • ✅ Good: /blog/nextjs-seo-checklist

  • ❌ Bad: /blog/post?id=123&category=nextjs or /blog/NextJs_Seo_Checklist

By thinking about your URL structure, you're building an SEO-friendly foundation for your marketing site.

Structured data (Schema Markup):

Structured data, often implemented using Schema.org vocabulary in JSON-LD format, is a powerful way to provide search engines with explicit information about the content on your page. It's like adding a detailed label to your product, telling the search engine exactly what it is, its price, reviews, etc.

Structured data can enable rich snippets in search results (e.g., star ratings, product prices, event dates), which can significantly increase click-through rates for your marketing site.

Basic JSON-LD for an article

You can embed JSON-LD directly in your page components or layouts. For an article on a marketing blog, you might use the Article schema:

// app/blog/[slug]/page.tsx (or a component that renders article content)

import Script from 'next/script';

interface ArticlePageProps {
  article: {
    headline: string;
    image: string[];
    datePublished: string;
    dateModified: string;
    author: { name: string };
    publisher: { name: string; logo: string };
    description: string;
  };
}

export default function ArticlePage({ article }: ArticlePageProps) {
  const schemaData = {
    "@context": "https://schema.org",
    "@type": "Article",
    "headline": article.headline,
    "image": article.image,
    "datePublished": article.datePublished,
    "dateModified": article.dateModified,
    "author": {
      "@type": "Person",
      "name": article.author.name
    },
    "publisher": {
      "@type": "Organization",
      "name": article.publisher.name,
      "logo": {
        "@type": "ImageObject",
        "url": article.publisher.logo
      }
    },
    "description": article.description
  };

  return (
    <article>
      <h1>{article.headline}</h1>
      {/* Article content goes here */}
      <script
        id="article-schema"
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaData) }}
      />
    </article>
  );
}

This JSON-LD snippet, placed within a Script tag with type="application/ld+json", provides search engines with a clear, machine-readable summary of your article. There are many other schema types (Product, LocalBusiness, Event, FAQPage, etc.) that are highly relevant for marketing sites. Use the ones that best describe your content to maximize your visibility in search results.

Next js supports SSR, SSG, ISR

Conclusion

Building a high-performing, SEO-friendly site with Next.js App Router requires technical skill, strategic planning, and ongoing optimization. By using features like Server Components and optimizing critical elements, your Next.js marketing site can offer a seamless, engaging experience that drives business growth. We've explored Suspense, next/image, metadata, sitemaps, and the importance of clean URLs and structured data.


Bring Your Ideas to Life 🚀

If you need help with a React project let’s get in touch.

Lucky Media is proud to be recognized as a leading Next.js Development Agency

FAQs

What's the biggest mistake developers make with Next.js performance?

The single biggest mistake is treating Next.js like a traditional React SPA and forcing too much client-side rendering and data fetching. Next.js, especially with the App Router, is designed for server-first rendering. Developers often default to useEffect for data fetching in Client Components, leading to unnecessary client-side JavaScript, waterfall requests, and slower LCP. The solution is to embrace Server Components for data fetching and rendering static/mostly static UI, and use Client Components only for interactivity.

How often should I run Lighthouse audits?

Use it regularly and integrate Lighthouse (or similar tools like Web Vitals Chrome extension) into your development workflow to catch issues early. Before major deployments or content pushes, run full audits. For production, set up continuous monitoring with tools like Google Search Console, PageSpeed Insights API, or third-party RUM (Real User Monitoring) solutions. This allows you to track performance trends and react quickly to regressions. Aim for at least weekly checks during active development and monthly deep dives for stable sites.

Can I achieve a perfect 100 Lighthouse score for every page?

While a perfect 100 is achievable and a great goal, it's not always strictly necessary or practical for every single page, especially for highly dynamic or complex applications. The focus should be on providing an excellent user experience and meeting Core Web Vitals scores. Some pages might have unavoidable third-party scripts or dynamic content that make a perfect score challenging. Prioritize critical marketing pages (homepage, landing pages, key product pages) for the highest scores, and keep all other pages at least in the green (good) range for Core Web Vitals.

Is SEO only about keywords?

While keywords are still important for signaling relevance, modern SEO is a much broader discipline. It includes technical SEO (site speed, mobile-friendliness, crawlability, indexability, structured data, sitemaps, robots.txt), on-page SEO (content quality, user intent, headings, internal linking), and off-page SEO (backlinks, brand mentions). For marketing sites, it's important to understand your target audience's search intent and provide high-quality, valuable content that naturally uses keywords.

How do Server Components impact SEO?

Server Components have a positive impact on SEO. By rendering on the server, they produce fully formed HTML that search engine crawlers can easily read and understand without needing to execute JavaScript. This keeps your content immediately available for indexing, improving crawlability and indexability. It also contributes to faster page load times (LCP), which is a direct ranking factor for Google.

Technologies

Next.jsBusiness
Lokman Musliu Founder and CEO of Lucky Media
Lokman Musliu

Founder and CEO of Lucky Media

Stay up-to-date

Be updated with all news, products and tips we share!

Let’s chat

We partner with a limited number of brands each quarter to ensure senior-level attention on every project.

lokman and arlind headshots
Teamwork

Related posts

August 26, 2024

Laravel guide for Next.js developers
Laravel
Next.js