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
npm install- 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.localfor development). Never in code or the browser. - Deploy. Then add the webhook
https://YOUR-SITE/api/revalidateon the same page and put its secret inSUPERDUPERSEO_WEBHOOK_SECRET. - 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
{
"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
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
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
import { createClient } from '@superduperseo/content'
/** Server-side only: reads SUPERDUPERSEO_CONTENT_KEY. */
export const cms = createClient()
app/layout.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
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
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
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
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
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
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
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
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
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
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
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
# 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=