# Starter: Next.js (App Router)

A complete, SEO-correct blog on your domain from SuperDuperSEO, rendered on the server. Copy the files you need into your site, keep your design, and keep every URL. Every file is below; this page as markdown is one fetch: https://superduperseo.com/docs/starter-nextjs.md.

A blog that reads its pages, SEO tags and sitemap from SuperDuperSEO, SEO-correct by default: server-rendered pages, one `<h1>`, our canonical, robots, Open Graph and structured data, 301/410 from our redirects, sitemap, RSS, `llms.txt` and the IndexNow key file. Pages are cached for a day and refreshed by our webhook the moment they change.

## Set up

1. `npm install`
2. In SuperDuperSEO, open Domains, your site, Content API: create a site key. Put it in the **server** environment as `SUPERDUPERSEO_CONTENT_KEY` (Vercel/Netlify/Cloudflare project settings, or `.env.local` for development). Never in code or the browser.
3. Deploy. Then add the webhook `https://YOUR-SITE/api/revalidate` on the same page and put its secret in `SUPERDUPERSEO_WEBHOOK_SECRET`.
4. SuperDuperSEO checks your live pages (canonical, title, description, structured data, server rendering) and marks the site Verified.

## Adding it to an existing Next.js site

Copy `lib/cms.ts`, `proxy.ts` (`middleware.ts` on Next 15 or older; or merge its redirect check into yours), `app/blog/**`, `app/sitemap.xml`, `app/llms.txt`, `app/api/**` and the rewrite in `next.config.ts`. Remove your own Article/BlogPosting JSON-LD on these pages (ours is the one), and keep each post at exactly `post.path`.

## Files

### `package.json`

````json
{
  "name": "superduperseo-nextjs-starter",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start"
  },
  "dependencies": {
    "@superduperseo/content": "^0.1.0",
    "next": "^16.4.0",
    "react": "19.2.3",
    "react-dom": "19.2.3"
  },
  "devDependencies": {
    "@types/node": "^20",
    "@types/react": "^19",
    "typescript": "^5"
  }
}
````

### `next.config.ts`

````ts
import type { NextConfig } from 'next'

const config: NextConfig = {
  // Blog URLs have no trailing slash (/blog/my-post); /blog/my-post/ 301s to it.
  trailingSlash: false,
  async rewrites() {
    // The IndexNow key file (/{key}.txt) is answered by app/api/indexnow/[key].
    return [{ source: '/:key([A-Za-z0-9-]{8,128}).txt', destination: '/api/indexnow/:key' }]
  },
}

export default config
````

### `proxy.ts`

````ts
import { NextResponse, type NextRequest } from 'next/server'
import { cms } from './lib/cms'

/**
 * Our redirects: 301 for moved pages, 410 for removed ones, before any page
 * renders. (Next.js 16 "proxy"; on Next 15 or older, name this file
 * middleware.ts and the function middleware.)
 */
export async function proxy(req: NextRequest) {
  try {
    const path = req.nextUrl.pathname.replace(/\/+$/, '') || '/'
    const hit = (await cms.redirects()).find(r => r.from === path)
    if (hit?.to) return NextResponse.redirect(hit.to, 301)
    if (hit) return new NextResponse('Gone', { status: 410, headers: { 'X-Robots-Tag': 'noindex' } })
  } catch {
    // SuperDuperSEO unreachable and nothing cached yet: render normally.
  }
  return NextResponse.next()
}

export const config = { matcher: ['/blog/:path*'] }
````

### `lib/cms.ts`

````ts
import { createClient } from '@superduperseo/content'

/** Server-side only: reads SUPERDUPERSEO_CONTENT_KEY. */
export const cms = createClient()
````

### `app/layout.tsx`

````tsx
import type { ReactNode } from 'react'

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="en">
      <body style={{ maxWidth: 760, margin: '0 auto', padding: '24px 16px', fontFamily: 'system-ui, sans-serif', lineHeight: 1.6 }}>
        {children}
      </body>
    </html>
  )
}
````

### `app/page.tsx`

````tsx
import Link from 'next/link'

export default function Home() {
  return (
    <main>
      <h1>Welcome</h1>
      <p><Link href="/blog">Read the blog</Link></p>
    </main>
  )
}
````

### `app/blog/page.tsx`

````tsx
import type { Metadata } from 'next'
import Link from 'next/link'
import { cms } from '../../lib/cms'

export const revalidate = 86400

export async function generateMetadata(): Promise<Metadata> {
  const site = await cms.site()
  return {
    title: `Blog - ${site?.name ?? ''}`,
    description: site?.description ?? undefined,
    alternates: {
      canonical: `${site?.url.replace(/\/$/, '')}${site?.routes.index ?? '/blog'}`,
      ...(site?.feed ? { types: { 'application/rss+xml': site.feed } } : {}),
    },
  }
}

export default async function BlogIndex({ searchParams }: { searchParams: Promise<{ page?: string }> }) {
  const page = Math.max(1, Number((await searchParams).page) || 1)
  const index = await cms.posts(page)
  return (
    <main>
      <h1>Blog</h1>
      {(index?.posts ?? []).map(p => (
        <article key={p.id} style={{ marginBottom: 32 }}>
          <h2><Link href={p.path}>{p.title}</Link></h2>
          {p.summary && <p>{p.summary}</p>}
          <small>{new Date(p.published_at).toLocaleDateString('en', { dateStyle: 'long' })}{p.author ? ` · ${p.author.name}` : ''} · {p.reading_time} min read</small>
        </article>
      ))}
      <nav>
        {page > 1 && <Link href={page === 2 ? '/blog' : `/blog?page=${page - 1}`}>Newer</Link>}{' '}
        {index?.next && <Link href={`/blog?page=${page + 1}`}>Older</Link>}
      </nav>
    </main>
  )
}
````

### `app/blog/[slug]/page.tsx`

````tsx
import type { Metadata } from 'next'
import Link from 'next/link'
import { notFound, permanentRedirect } from 'next/navigation'
import { metadataFor, jsonLd } from '@superduperseo/content/next'
import { cms } from '../../../lib/cms'

// Cached for a day; our webhook (app/api/revalidate) refreshes a page the moment it changes.
export const revalidate = 86400

export async function generateStaticParams() {
  try {
    return (await cms.allPosts()).map(p => ({ slug: p.slug }))
  } catch {
    return []
  }
}

async function load(slug: string) {
  const hit = await cms.resolve(`/blog/${slug}`)
  if (hit?.type === 'redirect') permanentRedirect(hit.to)
  if (hit?.type !== 'post') notFound()
  return hit.post
}

export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {
  return metadataFor(await load((await params).slug)) as Metadata
}

export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) {
  const post = await load((await params).slug)
  const image = post.feature_image
  return (
    <main>
      {/* One structured-data block per page: ours (remove any other Article schema). */}
      <script type="application/ld+json" dangerouslySetInnerHTML={jsonLd(post)} />
      <nav aria-label="Breadcrumb">
        {post.breadcrumbs.map((b, i) => (
          <span key={b.url}>{i > 0 && ' / '}{i < post.breadcrumbs.length - 1 ? <Link href={new URL(b.url).pathname}>{b.name}</Link> : b.name}</span>
        ))}
      </nav>
      <article>
        <h1>{post.title}</h1>
        {/* The byline ("By ...") is part of post.html, signed by the site's author. */}
        <p><small>
          <time dateTime={post.published_at}>{new Date(post.published_at).toLocaleDateString('en', { dateStyle: 'long' })}</time>
          {post.updated_at !== post.published_at && <> · Updated <time dateTime={post.updated_at}>{new Date(post.updated_at).toLocaleDateString('en', { dateStyle: 'long' })}</time></>}
          {' · '}{post.reading_time} min read
        </small></p>
        {image && (
          // The hero is the largest element: load it first (LCP), with its size (no layout shift).
          // eslint-disable-next-line @next/next/no-img-element
          <img src={image.url} srcSet={image.srcset ?? undefined} sizes="(max-width: 760px) 100vw, 760px" alt={image.alt ?? ''} width={image.width ?? undefined} height={image.height ?? undefined} fetchPriority="high" style={{ width: '100%', height: 'auto' }} />
        )}
        {image?.credit && <small>Photo: {image.credit.url ? <a href={image.credit.url} rel="nofollow">{image.credit.name}</a> : image.credit.name}</small>}
        {post.toc.length > 2 && (
          <nav aria-label="Contents">
            <ul>{post.toc.filter(t => t.level === 2).map(t => <li key={t.id}><a href={`#${t.id}`}>{t.text}</a></li>)}</ul>
          </nav>
        )}
        <div dangerouslySetInnerHTML={{ __html: post.html }} />
      </article>
      {post.related.length > 0 && (
        <aside>
          <h2>Related</h2>
          <ul>{post.related.map(r => <li key={r.slug}><Link href={new URL(r.url).pathname}>{r.title}</Link></li>)}</ul>
        </aside>
      )}
    </main>
  )
}
````

### `app/blog/author/[slug]/page.tsx`

````tsx
import type { Metadata } from 'next'
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { metadataFor, jsonLd } from '@superduperseo/content/next'
import { cms } from '../../../../lib/cms'

export const revalidate = 86400

export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {
  const author = await cms.author((await params).slug)
  return author ? (metadataFor(author) as Metadata) : {}
}

export default async function AuthorPage({ params }: { params: Promise<{ slug: string }> }) {
  const author = await cms.author((await params).slug)
  if (!author) notFound()
  return (
    <main>
      <script type="application/ld+json" dangerouslySetInnerHTML={jsonLd(author)} />
      <h1>{author.name}</h1>
      {author.job_title && <p>{author.job_title}</p>}
      {author.bio && <p>{author.bio}</p>}
      <ul>{author.posts.map(p => <li key={p.id}><Link href={p.path}>{p.title}</Link></li>)}</ul>
    </main>
  )
}
````

### `app/blog/topic/[slug]/page.tsx`

````tsx
import type { Metadata } from 'next'
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { metadataFor, jsonLd } from '@superduperseo/content/next'
import { cms } from '../../../../lib/cms'

export const revalidate = 86400

export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {
  const topic = await cms.topic((await params).slug)
  // Topics with fewer than 3 posts come back noindex (no thin pages).
  return topic ? (metadataFor(topic) as Metadata) : {}
}

export default async function TopicPage({ params }: { params: Promise<{ slug: string }> }) {
  const topic = await cms.topic((await params).slug)
  if (!topic) notFound()
  return (
    <main>
      <script type="application/ld+json" dangerouslySetInnerHTML={jsonLd(topic)} />
      <h1>{topic.name}</h1>
      <ul>{topic.posts.map(p => <li key={p.id}><Link href={p.path}>{p.title}</Link></li>)}</ul>
    </main>
  )
}
````

### `app/blog/rss.xml/route.ts`

````ts
import { cms } from '../../../lib/cms'

export const revalidate = 3600

export async function GET() {
  return new Response((await cms.feedXml()) ?? '', { headers: { 'Content-Type': 'application/rss+xml; charset=utf-8' } })
}
````

### `app/sitemap.xml/route.ts`

````ts
import { cms } from '../../lib/cms'

export const revalidate = 3600

/** Our sitemap (honest lastmod, images). Merge your own pages in if you have a sitemap already. */
export async function GET() {
  const xml = await cms.sitemapXml()
  return new Response(xml ?? '', { headers: { 'Content-Type': 'application/xml; charset=utf-8' } })
}
````

### `app/llms.txt/route.ts`

````ts
import { cms } from '../../lib/cms'

export const revalidate = 3600

/** For AI search engines (llmstxt.org). */
export async function GET() {
  return new Response((await cms.llmsTxt()) ?? '', { headers: { 'Content-Type': 'text/plain; charset=utf-8' } })
}
````

### `app/robots.ts`

````ts
import type { MetadataRoute } from 'next'
import { cms } from '../lib/cms'

export default async function robots(): Promise<MetadataRoute.Robots> {
  const site = await cms.site().catch(() => null)
  return {
    rules: [{ userAgent: '*', allow: '/' }],
    sitemap: site ? `${site.url.replace(/\/$/, '')}/sitemap.xml` : undefined,
  }
}
````

### `app/api/revalidate/route.ts`

````ts
import { revalidatePath } from 'next/cache'
import { revalidateRoute } from '@superduperseo/content/next'

/** Our webhook: refreshes a page the moment it changes. Add https://YOUR-SITE/api/revalidate in SuperDuperSEO. */
export const POST = revalidateRoute({
  secret: process.env.SUPERDUPERSEO_WEBHOOK_SECRET ?? '',
  revalidate: revalidatePath,
  alsoRevalidate: ['/blog', '/sitemap.xml', '/blog/rss.xml', '/llms.txt'],
})
````

### `app/api/indexnow/[key]/route.ts`

````ts
import { cms } from '../../../../lib/cms'

/** Answers /{key}.txt (see next.config.ts) so search engines accept our IndexNow pings. */
export async function GET(_req: Request, { params }: { params: Promise<{ key: string }> }) {
  const key = await cms.indexNowKey()
  if (!key || key !== (await params).key) return new Response('Not found', { status: 404 })
  return new Response(key, { headers: { 'Content-Type': 'text/plain; charset=utf-8' } })
}
````

### `.env.example`

````bash
# From SuperDuperSEO -> Domains -> your site -> Content API. Server-side only.
SUPERDUPERSEO_CONTENT_KEY=
# Shown when you add the webhook https://YOUR-SITE/api/revalidate in SuperDuperSEO.
SUPERDUPERSEO_WEBHOOK_SECRET=
````
