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.