1. Contextual Introduction
In the last decade, page‑load speed and semantic structuring of websites have become critical factors for search‑engine ranking. Google states that 70 % of users abandon a page that takes more than 5 s to load and that Core Web Vitals have been part of the ranking algorithm since 2021. In the JavaScript ecosystem, Next.js has become the reference solution for React applications with hybrid rendering (SSR, SSG, ISR).
The App Router, introduced in Next.js 13, replaces the old pages/ system and enables an architecture based on server and client components, nested routes, and shared layouts. This flexibility opens new possibilities for technical SEO, but also entails design decisions that must align with indexing, speed, and accessibility requirements.
Globally, organic traffic accounts for ≈ 53 % of all website visits, and a 0.5 % shift in average result position can translate into tens of thousands of additional visits for an average e‑commerce site. That’s why solving technical SEO challenges in Next.js (App Router) is critical today: competition is getting fiercer and search engines penalize sites that do not meet performance and structured‑data standards.
In this article we will break down the fundamentals, the recommended architecture, and the step‑by‑step implementation, with production‑ready code examples, real‑world use cases, and a comparison of rendering strategies. By the end you will have a concrete action plan to take your Next.js application to the next level of visibility.
2. Fundamentals and Architecture
2.1 Key Concepts
| Concept | What it is | Why it matters for SEO |
|---|---|---|
| App Router | Routing system based on the app/ directory with server components (.server.tsx) and client components (.tsx). | Allows per‑route server‑rendered HTML, ensuring crawlers receive rendered content. |
| Server‑Components (SC) | Components that run exclusively on the server and never reach the client bundle. | Reduce JavaScript sent to the browser, improving LCP and FID. |
| Metadata API | Export of metadata from each route to define <title>, <meta> and openGraph. | Centralizes SEO tag management and prevents duplicates. |
Dynamic Routes + generateStaticParams | Static generation of dynamic routes at build time or on‑demand (ISR). | Guarantees friendly, pre‑rendered URLs for crawlers. |
| Edge Middleware | Code that runs on the Edge layer before the request reaches the page. | Enables 301 redirects, canonicalization, and bot detection without touching page code. |
2.2 Design Decisions and Trade‑offs
| Decision | Advantages | Disadvantages |
|---|---|---|
| SSR (Server‑Side Rendering) on all routes | Content always up‑to‑date; SEO guaranteed. | Higher response time under heavy load; higher infrastructure cost. |
| SSG (Static Site Generation) with ISR | Excellent performance; low cost; on‑demand updates. | Not suitable for data that changes every second (e.g., real‑time prices). |
| Heavy use of Server‑Components | Bundle size reduction, better Core Web Vitals. | Cannot use React local state; requires careful separation of logic. |
| Edge Middleware for redirects | Minimal latency; centralized logic. | Limited to 50 ms execution; cannot perform heavy I/O. |
2.3 Conceptual Diagram (described in text)
- Client → requests
GET /products/123. - Edge Middleware intercepts, checks
User‑Agentand applies canonicalization. - App Router resolves the route
app/products/[id]/page.tsx. - Server‑Component
ProductDetail.server.tsxqueries the database via Supabase (PostgreSQL) using the Repository Pattern. - Metadata exported in
page.tsxgenerates<title>,<meta description>andJSON‑LD. - HTML is sent to the client, React Hydration occurs only in components marked as client.
3. Practical Implementation
Below is the evolution of a product‑detail page, from a naïve version (client‑only React) to an optimized architecture with Server‑Components, Metadata, and Edge Middleware.
3.1 Version 1 – Basic Approach (Client‑only)
// app/productos/[id]/page.tsx
import React, { useEffect, useState } from "react";
import axios from "axios";
interface Producto {
id: string;
nombre: string;
precio: number;
descripcion: string;
}
/**
* Client page that loads data via fetch in the browser.
* Not crawler‑friendly because the initial HTML is empty.
*/
export default function ProductoDetalle() {
const [producto, setProducto] = useState<Producto | null>(null);
const [error, setError] = useState<string>("");
// Extract the id from the URL using the browser API
const id = new URLSearchParams(window.location.search).get("id") ?? "";
useEffect(() => {
if (!id) {
setError("Product ID not specified");
return;
}
axios
.get<Producto>(`/api/productos/${id}`)
.then((res) => setProducto(res.data))
.catch(() => setError("Error loading product"));
}, [id]);
if (error) return <p>{error}</p>;
if (!producto) return <p>Loading...</p>;
return (
<article>
<h1>{producto.nombre}</h1>
<p>{producto.descripcion}</p>
<strong>{producto.precio.toLocaleString("es-ES", { style: "currency", currency: "EUR" })}</strong>
</article>
);
}
SEO Issues
- The HTML delivered to the crawler is just a
<div id="root"></div>. - No
<title>or<meta description>tags. - Content depends on JavaScript; Google renders it, but other bots may not.
3.2 Version 2 – Server‑Component + Metadata (SSR)
// app/productos/[id]/page.tsx
import type { Metadata } from "next";
import ProductDetailServer from "./ProductDetail.server";
import { notFound } from "next/navigation";
/**
* Export static metadata based on the route id.
* Next.js runs this function on the server before rendering.
*/
export async function generateMetadata({ params }: { params: { id: string } }): Promise<Metadata> {
const producto = await fetchProducto(params.id);
if (!producto) {
// If it does not exist, return generic metadata and let the 404 page handle it.
return {
title: "Product not found",
description: "The requested product is not available.",
};
}
return {
title: `${producto.nombre} - Online Store`,
description: producto.descripcion.slice(0, 150),
openGraph: {
title: producto.nombre,
description: producto.descripcion,
images: [{ url: producto.imagenUrl }],
},
robots: "index, follow",
};
}
/**
* Helper function that queries the database via Supabase.
* Runs on the server, so we can use secret credentials.
*/
async function fetchProducto(id: string) {
const supabase = (await import("@supabase/supabase-js")).createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);
const { data, error } = await supabase.from("productos").select("*").eq("id", id).single();
if (error) {
console.error("Error fetching product:", error);
return null;
}
return data;
}
/**
* Render the server component that already has the data.
*/
export default async function ProductoPage({ params }: { params: { id: string } }) {
const producto = await fetchProducto(params.id);
if (!producto) {
notFound(); // Throws 404 and Next.js shows the error page.
}
return <ProductDetailServer producto={producto} />;
}
// app/productos/[id]/ProductDetail.server.tsx
import type { ReactNode } from "react";
interface Props {
producto: {
id: string;
nombre: string;
precio: number;
descripcion: string;
imagenUrl: string;
};
}
/**
* Server Component: no JavaScript is sent to the client.
* Contains only static markup, ideal for SEO.
*/
export default function ProductDetailServer({ producto }: Props): ReactNode {
return (
<article>
<h1>{producto.nombre}</h1>
<img src={producto.imagenUrl} alt={producto.nombre} loading="lazy" width={600} height={400} />
<p>{producto.descripcion}</p>
<strong>
{producto.precio.toLocaleString("es-ES", { style: "currency", currency: "EUR" })}
</strong>
</article>
);
}
Improvements
- The HTML arrives already rendered with real content.
metadatagenerates<title>,<meta description>and OpenGraph tags.- Unnecessary JavaScript is not sent, improving LCP.
3.3 Version 3 – ISR + Edge Middleware + Repository Pattern
3.3.1 Repository (data access)
// src/repositories/productRepository.ts
import { createClient } from "@supabase/supabase-js";
export interface Producto {
id: string;
nombre: string;
precio: number;
descripcion: string;
imagenUrl: string;
}
/**
* Repository Pattern implementation.
* Centralizes Supabase access and allows mocking in tests.
*/
export class ProductoRepository {
private supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);
async findById(id: string): Promise<Producto | null> {
const { data, error } = await this.supabase
.from<Producto>("productos")
.select("*")
.eq("id", id)
.single();
if (error) {
console.error("Supabase error:", error);
return null;
}
return data;
}
async findAllIds(): Promise<string[]> {
const { data, error } = await this.supabase.from("productos").select("id");
if (error) {
console.error("Supabase error:", error);
return [];
}
return data.map((row) => row.id);
}
}
3.3.2 ISR – generateStaticParams
// app/productos/[id]/page.tsx (continues)
import { ProductoRepository } from "@/src/repositories/productRepository";
export const revalidate = 60; // ISR: re‑generate every 60 s
export async function generateStaticParams() {
const repo = new ProductoRepository();
const ids = await repo.findAllIds();
return ids.map((id) => ({ id }));
}
/* The rest of the file remains the same, but now the page is
statically generated at build time and revalidated each minute. */
3.3.3 Edge Middleware (canonical + bot detection)
// middleware.ts (located at the project root)
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
/**
* Middleware executed on the Edge Network.
* - Redirects HTTP → HTTPS.
* - Adds canonical link if the URL contains tracking parameters.
* - Blocks known malicious bots.
*/
export function middleware(request: NextRequest) {
const url = request.nextUrl.clone();
// 1️⃣ Enforce HTTPS
if (url.protocol === "http") {
url.protocol = "https";
return NextResponse.redirect(url);
}
// 2️⃣ Canonical: remove ?utm_* from the query string
const cleanSearch = url.searchParams
.entries()
.filter(([k]) => !k.startsWith("utm_"))
.map(([k, v]) => `${k}=${v}`)
.join("&");
if (cleanSearch !== url.search) {
url.search = cleanSearch;
const response = NextResponse.rewrite(url);
response.headers.set("Link", `<${url.toString()}>; rel="canonical"`);
return response;
}
// 3️⃣ Simple block of suspicious bots
const ua = request.headers.get("user-agent")?.toLowerCase() ?? "";
if (ua.includes("curl") || ua.includes("wget")) {
return new NextResponse("Access denied", { status: 403 });
}
// Continue with the normal request
return NextResponse.next();
}
// Apply middleware to all routes except /api/*
export const config = {
matcher: "/((?!api/).*)",
};
Benefits of Version 3
- ISR combines SSG speed with fresh data (re‑validation every minute).
- Edge Middleware ensures crawlers receive canonical URLs and avoids duplicate content.
- Repository Pattern makes unit testing easy and keeps data‑access logic isolated.
3.4 Additional Code Examples (5‑8 blocks)
3.4.1 Structured JSON‑LD (Product schema)
// app/productos/[id]/StructuredData.tsx
import type { Producto } from "@/src/repositories/productRepository";
interface Props {
producto: Producto;
}
/**
* Generates JSON‑LD following schema.org/Product.
* Inserted into <head> via next/head.
*/
export default function StructuredData({ producto }: Props) {
const jsonLd = {
"@context": "https://schema.org/",
"@type": "Product",
name: producto.nombre,
image: producto.imagenUrl,
description: producto.descripcion,
sku: producto.id,
offers: {
"@type": "Offer",
priceCurrency: "EUR",
price: producto.precio,
availability: "https://schema.org/InStock",
},
};
return (
<script
type="application/ld+json"
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
);
}
3.4.2 Lazy‑loaded client component (e.g., cart button)
// app/components/CartButton.client.tsx
"use client";
import { useState } from "react";
/**
* Client‑only component that loads on demand.
* Ideal for interactivity that does not affect SEO.
*/
export default function CartButton() {
const [added, setAdded] = useState(false);
const
