Skip to main content
All documentation

The Next.js integration

Install the Dynamic SEO package in a Next.js App Router site, wire it through next.config or, on Cache Components, per page, and read the verify step correctly.

Next.js is the second platform you can install today. The package is a download, the wiring is one wrapper in next.config, and one question matters: whether your site runs Cache Components. That decides which of two paths you take.

Before you start

The package is not on npm. The Integrate page's Next.js guide has the download, and its steps are printed with your own domain and the start of your site key. Everything here is the same guide with the reasoning attached.

1. Download and install

Put the tarball in your repository, for example under vendor/, and install it from that path. A committed file is what survives a clean build on your host; a URL to a moving artifact does not.

npm install ./vendor/dynamic-seo-nextjs.tgz

2. The site key and the three variables

Copy the key from step 2 of the guide. It goes into .env locally and into your host's environment variables for production, together with the edge worker's URL and your domain as registered in Dynamic SEO:

DYNAMIC_SEO_WORKER_URL=https://seo.dynamicseo.com
DYNAMIC_SEO_DOMAIN=example.com
DYNAMIC_SEO_SITE_KEY=sk_...

The worker is the same edge the WordPress plugin talks to. A Next.js site therefore shows up in the Delivery card the same way.

Redeploy after changing a variable: it applies once the site is rebuilt. Vercel stores production variables as sensitive, so they read back empty. Whether the key matches shows in the app's verify step instead, which puts the start of the key being sent beside the site's own.

3. Choose your path

Without Cache Components (most sites, including next-intl and static or ISR sites): wrap next.config. That is the whole integration: no proxy, no change to your layout, nothing else to install.

// next.config.ts
import { withDynamicSEO } from '@dynamic-seo/middleware/nextjs/config'

const nextConfig = { /* your config */ }

export default withDynamicSEO({
  apiUrlEnv: 'DYNAMIC_SEO_WORKER_URL',
  domainEnv: 'DYNAMIC_SEO_DOMAIN',
  siteKeyEnv: 'DYNAMIC_SEO_SITE_KEY',
})(nextConfig)

If you already wrap next.config with next-intl or MDX, put this one outermost: withDynamicSEO({...})(withNextIntl(nextConfig)).

If the site has signed-in pages under app/, such as a dashboard, scope the wrapper with include so only the public pages are wrapped. For a route group named (marketing): include: [/\(marketing\)[\\/]/].

At build time the wrapper wraps every page's generateMetadata, whichever form the page exports it in, and merges what you publish over your own metadata. Anything you have not published keeps your value. A page's path is its own canonical on your domain, so a default locale without a prefix and translated paths both match the public URL the crawler recorded. Layouts are left alone. Pages stay static and regenerate every 5 minutes, so a publish appears within about 5 minutes. If the edge is unreachable during a build, your own metadata is served and the page tries again at the next regeneration.

Structured data works the same way. The wrapper finds your JSON-LD component, meaning a file that renders <script type="application/ld+json"> and exports a component whose name ends in JsonLd. It lays what you publish over your entities, type by type. A @graph stays one connected graph with your own @ids, and types you don't have are added to it.

With Cache Components (cacheComponents: true in next.config): the wrapper leaves your config unchanged and says so in the build log, because an uncached fetch in generateMetadata fails such a build. Merge the published SEO into each page's own metadata instead. The path comes from the page's params, the fetch sits behind use cache, and the static shell is prerendered with the tags in it:

// lib/dynamicseo.ts
import type { Metadata } from 'next'
import { cacheLife } from 'next/cache'
import { createDynamicSEO } from '@dynamic-seo/middleware/nextjs'

const dseo = createDynamicSEO({
  apiUrl: process.env.DYNAMIC_SEO_WORKER_URL!,
  domain: process.env.DYNAMIC_SEO_DOMAIN!,
  siteKey: process.env.DYNAMIC_SEO_SITE_KEY,
})

export async function withDynamicSeo(path: string, fallback: Metadata): Promise<Metadata> {
  'use cache'
  cacheLife('minutes')
  return dseo.metadata(path, fallback)
}
// app/[slug]/page.tsx, and the same in every page that has metadata
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params
  return withDynamicSeo(`/${slug}`, { title: 'Your own title' })
}

When nothing is published for a path, the fallback is what renders.

4. Verify, and what the answer means

The verify step asks the edge for your homepage with your site key, from our side. Three answers are possible:

  • Verified: the edge served published content for the homepage. The pipeline is live.
  • Key works, nothing published yet: the edge answered, but there is nothing published for the homepage. That is the normal state right after install. Publish a page and run the test again.
  • Key rejected: the key your installation sends is not this site's current key. Copy the current one from the guide, or regenerate and update every install.

Above the button, the step also says what your own site has done: whether it has called the edge, how many calls the last day got content, and when it was last seen. That is the serve log, the same numbers as the Delivery card on the dashboard, so a working install reads as working before anything is published.

Where to look when a page does not change

Work backwards. Was it published less than five minutes ago? Then the page has not regenerated yet. Is anything published for that path? Did your deploy pick up the environment variables? Does the page's head contain <meta name="dynamic-seo" content="active">? That tag is written on every served response, so its absence means the request never got content, and the Delivery card says why.