Patchlog
Patchlog
Back to blog
tutorial

How to Add a Changelog to a Next.js App (App Router and Pages Router)

Use next/script, not useEffect. Which layout file to edit in each router, which strategy prop to pass, and why the widget survives client side navigation without remounting.

· 5 min read

Most guides tell you to inject a third party script in Next.js with a useEffect that appends to document.body. That works on a static site and causes duplicate widgets on a Next.js app, because the component it lives in can mount more than once.

Next.js ships a component for exactly this. Use it.

App Router

Put it in the root layout, app/layout.tsx, so it loads once for the whole app:

import Script from 'next/script'

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <body>
        {children}
        <Script
          src="https://patchlog.io/widget.js"
          data-project="YOUR_PROJECT_ID"
          strategy="lazyOnload"
        />
      </body>
    </html>
  )
}

data-project is the project ID, not the slug. The slug is what your hosted changelog page is served from; the ID is what the widget looks up. Copy the snippet from your project's widget settings rather than assembling it by hand.

Pages Router

Same idea, different file. Edit pages/_app.tsx:

import Script from 'next/script'
import type { AppProps } from 'next/app'

export default function App({ Component, pageProps }: AppProps) {
  return (
    <>
      <Component {...pageProps} />
      <Script
        src="https://patchlog.io/widget.js"
        data-project="YOUR_PROJECT_ID"
        strategy="lazyOnload"
      />
    </>
  )
}

Do not put it in _document.tsx. That file renders once on the server and is not the right place for a script that needs the client, and next/script is not designed to run there.

Which strategy to use

next/script takes a strategy prop and the choice matters more than it looks.

lazyOnload waits until the browser is idle after everything else has loaded. That is the right default for a changelog widget: nobody is waiting on it, and it should never compete with your actual application code for bandwidth on first paint.

afterInteractive loads it earlier, right after the page becomes interactive. Use it only if you have a reason to want the widget visible sooner, for instance if the unread badge is part of how you drive people to a specific announcement.

beforeInteractive is wrong for this. It blocks hydration on a script that has nothing to do with your app working, and it only functions in the root layout anyway.

Why this survives navigation

Next.js does client side transitions, so moving between routes does not reload the document. A script placed in the root layout runs once on the initial load and the widget stays mounted from then on. That is the behaviour you want, and it is the reason to use the layout rather than a per page component.

It is also why the useEffect approach misbehaves. If you inject the script from a component that unmounts on navigation, you either tear the widget down every time the user moves, or, if you forget the cleanup function, you append a fresh copy of the script on every mount and end up with several widgets stacked on the page.

Only showing it to signed in users

Wrap it in whatever your session check is. With the App Router and a server side session:

import Script from 'next/script'
import { auth } from '@/auth'

export default async function RootLayout({ children }) {
  const session = await auth()

  return (
    <html lang="en">
      <body>
        {children}
        {session && (
          <Script
            src="https://patchlog.io/widget.js"
            data-project="YOUR_PROJECT_ID"
            strategy="lazyOnload"
          />
        )}
      </body>
    </html>
  )
}

The widget itself does not know or care who your users are. It renders the same entries to everyone who sees it, so gating it is purely about who you want to show it to.

If it does not appear

Open the console and check two requests in order.

First, widget.js should return 200. If it does not, and you are running a Content Security Policy through next.config.js headers or middleware, you need https://patchlog.io in both script-src and connect-src. Missing the second is the more confusing failure, because the script loads and then quietly fetches nothing.

Second, a call to patchlog.io/api/widget/YOUR_PROJECT_ID should also return 200. A 404 there means the data-project value is wrong, and nine times out of ten the slug was pasted in instead of the ID.

If both return 200 and there is still nothing on screen, the project has no published entries. The widget deliberately renders nothing rather than an empty shell, which is sensible in production and mildly baffling while you are setting it up.

Styling

Next.js projects usually run Tailwind, and Tailwind's preflight resets bare elements globally. That is normally where embedded widgets start to look wrong.

Patchlog renders in a Shadow DOM, so your reset does not reach inside it and its styles do not leak into your page. There is nothing to configure and no stylesheet to load in a particular order.

Drafting the entries from your commits

The install is the easy part. The reason most changelogs go stale is that writing the entries is a separate chore that never gets prioritised.

If your repo is on GitHub, the official Action sends each push for drafting, and on your chosen cadence an LLM turns the buffered commits into one draft entry per user facing change for you to review before anything goes live:

      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: michaelyousrie/patchlog-action@v1
        with:
          token: ${{ secrets.PATCHLOG_TOKEN }}
          project-id: ${{ vars.PATCHLOG_PROJECT_ID }}

fetch-depth: 0 matters, since without the full history the action cannot see which commits are new. Lockfiles, .env, vendor, node_modules and build output are stripped on the runner and never sent.

The drafting is a paid feature. The widget, the hosted page and the RSS feed are free, which is enough to find out whether anyone reads it.

Keep your customers in the loop

Patchlog gives your app an in-app changelog widget and a hosted updates page in minutes. One script tag, no SDK.

Start for free →

Free plan available. No credit card required.