# Xabier Lameiro — full blog content > Auto-generated from the site source on build. English versions (canonical URLs). --- # Fix "Failed to replace env in config: ${NPM_TOKEN}" in npm and yarn - URL: https://xabierlameiro.com/blog/error/npm-token-solution-error - Author: Xabier Lameiro - Category: Error · Tags: npm, yarn, ci - Summary: How to fix the npm/yarn error "Failed to replace env in config: ${NPM_TOKEN}" in one minute: define the missing environment variable that your .npmrc expects, or remove the stale entry. # Possible solution for NPM - Failed to replace env in config: $\{NPM_TOKEN} Published: 01-03-2023 ## Quick answer npm or yarn found an `.npmrc` file that references an environment variable (`NPM_TOKEN`) that is not defined in your shell, so it cannot expand it. Either export the variable (`export NPM_TOKEN=your_token`), or find the `.npmrc` that references it — in the project root or in your user home directory — and remove the stale entry. Run the install with `--verbose` to see exactly which `.npmrc` files are being read. The error text itself is the giveaway: `Failed to replace env in config: ${{NPM_TOKEN}`. npm is telling you it tried to substitute an `${...}` placeholder and there was nothing to put in its place. ## Why it happens To install a private package from the GitHub registry you point npm at that registry and give it a token. The token lives in an environment variable so it never ends up committed, and the `.npmrc` references it by name: ```bash .npmrc @xabierlameiro:registry=https://npm.pkg.github.com/ //npm.pkg.github.com/:_authToken=${NPM_TOKEN} ``` When you run `npm install` or `yarn`, npm reads that file and expands every `${VAR}` against your current environment. If `NPM_TOKEN` is not set in that shell, it cannot replace the placeholder and aborts — that is the exact error. ## The `.npmrc` files npm actually reads This is the part that turns a one-minute fix into a two-hour one. npm does not read a single `.npmrc`; it merges several, and the closest one wins. In order of precedence: 1. Project: `./.npmrc` 2. User: `~/.npmrc` (on Windows, `C:\Users\\.npmrc`) 3. Global: `$PREFIX/etc/npmrc` 4. npm's built-in config So a line you added to your **home** `.npmrc` months ago is applied to **every** project you install, even ones that have no idea what `NPM_TOKEN` is. That is the usual reason the error shows up in a repo you never configured for the GitHub registry. ## Common causes, ranked - The variable is genuinely not defined in the shell you ran the command from. - The `.npmrc` lives in your home folder, not the project — so it leaks into unrelated installs. This was my case: I had saved it in `C:\Users\xabierlameiro\.npmrc`, and every project inherited the `${NPM_TOKEN}` reference. - The variable is set in a GUI app or a different terminal tab that did not inherit the export. - In CI, the secret exists but was never exposed to the step's environment. ## How I fixed it First I ran the install in verbose mode to see which files npm was reading: ```bash yarn install --verbose ``` The log lists the paths it checks for `.npmrc`, and that is where I saw it was pulling the config from my user folder. I **_deleted_** it from the user folder and recreated it in the root of the project, and the error was gone. If you instead want the token available everywhere, define it rather than removing the reference: ```bash export NPM_TOKEN=your_token # macOS / Linux setx NPM_TOKEN your_token # Windows, new shells only ``` `npm config ls -l` prints the fully merged configuration, which is the fastest way to confirm whether the token was picked up. ## Fixing it in CI The most common place this bites is continuous integration, because the runner is a fresh machine with none of your local exports. The fix is to hand the secret to the step as an environment variable — the same `NPM_TOKEN` name the `.npmrc` expects. In GitHub Actions: ```yaml workflow.yml - name: Install dependencies env: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} run: npm ci --legacy-peer-deps ``` If you generate the `.npmrc` on the runner instead of committing it, write the auth line straight into the home file before installing: ```bash echo "//npm.pkg.github.com/:_authToken=${NPM_TOKEN}" >> ~/.npmrc echo "@your-scope:registry=https://npm.pkg.github.com/" >> ~/.npmrc ``` Same idea in every CI provider: the `.npmrc` reference and the environment variable have to agree on the name, and the value has to reach the process that runs the install. ## Keep the token out of git One last thing, because it is a mistake I see often. The `.npmrc` that references `${NPM_TOKEN}` is safe to commit: it holds only the *name* of the variable, not its value. What must never reach git is an `.npmrc` with the literal token pasted in (`_authToken=ghp_...`). If you did that, the token is compromised the moment it is pushed — revoke and rotate it in GitHub, do not just delete the file, because git history keeps it. The rule of thumb: an `.npmrc` that only references env vars is fine to commit and actually helps your teammates; one with real secrets belongs in `.gitignore` and, better, should not exist. > Whether it happens locally or in a pipeline, the question is always the same: does the shell that runs npm actually have `NPM_TOKEN`, and is it reading the `.npmrc` you think it is? Answer those two and the error disappears. --- # Address already in use fix: free the port (EADDRINUSE) - URL: https://xabierlameiro.com/blog/error/solve-address-in-use-error - Author: Xabier Lameiro - Category: Error · Tags: node, cli - Summary: Address already in use fix for Node.js, Python, Docker, netcat and Playwright: find and kill the process holding the port on macOS, Linux and Windows. # Address already in use fix: free the port (EADDRINUSE) Published: 01-02-2023 ## Quick answer Another process is already listening on the port. Find it and kill it: - **macOS / Linux:** `lsof -i :3000` → `kill -9 ` - **Windows:** `netstat -ano | findstr :3000` → `taskkill /PID /F` - **Any OS, one command:** `npx kill-port 3000` …or just start your server on a different port. The full error looks like this: ```bash MAKINOTE$ yarn start yarn run v1.22.19 $ next start Error: listen EADDRINUSE: address already in use 0.0.0.0:3000 at Server.setupListenHandle [as _listen2] (node:net:1432:16) at listenInCluster (node:net:1480:12) at doListen (node:net:1629:7) at processTicksAndRejections (node:internal/process/task_queues:84:21) { code: 'EADDRINUSE', errno: -48, syscall: 'listen', address: '0.0.0.0', port: 3000 } ``` ## Why it happens `EADDRINUSE` means the address and port your server wants are already taken. On a dev machine the usual cause is a **previous dev server that did not shut down cleanly** — a crashed process, a detached background job, or a second terminal still running it. Less often another app genuinely owns the port: another service, a Docker container, or a system daemon. ## The same error in every other tool `EADDRINUSE` is not a Node.js thing. It is the operating system refusing the `bind()` call, and every runtime surfaces the same refusal in its own words. If you searched the exact string your tool printed and landed here, this is the translation table: | Tool | What it prints | | ------------- | --------------------------------------------------------------------- | | Node.js | `Error: listen EADDRINUSE: address already in use 0.0.0.0:3000` | | Python | `OSError: [Errno 48] Address already in use` (48 on macOS, 98 on Linux) | | netcat | `nc: Address already in use`, exit code 1 | | Docker | `bind: address already in use` | | Go | `listen tcp :8080: bind: address already in use` | | Playwright | `http://localhost:3000 is already used, make sure that nothing is running on the port/url` | The numbers in the Python message are the raw `errno` constant, and they differ per platform — **48 on macOS and the BSDs, 98 on Linux**. That trips people up when a container reproduces the same failure with a different number than their laptop. Reproduced on macOS 26.5: ```python errno.py >>> import socket >>> s1 = socket.socket(); s1.bind(('127.0.0.1', 51234)); s1.listen(1) >>> s2 = socket.socket(); s2.bind(('127.0.0.1', 51234)) OSError: [Errno 48] Address already in use ``` Whatever printed it, the diagnosis does not change: something already holds that port. The commands below work regardless of which tool hit the error. ## Which port is it, and who usually owns it Half of these reports are not a stale dev server at all — they are two different programs that default to the same port. The ones worth knowing: | Port | Usual owner | | ------------- | ------------------------------------------------------------------------ | | 3000 | Next.js, Create React App, Rails, Grafana | | 3001 | The second dev server you started after 3000 was taken | | 5000 / 7000 | On macOS, ControlCenter when **AirPlay Receiver** is enabled | | 5173 | Vite | | 8080 | Tomcat, Jenkins, and roughly every "alternative HTTP" default | | 9323 | Playwright's HTML report server | | 5432 / 6379 | PostgreSQL / Redis — usually a container fighting a local install | The macOS 5000 case is the one that wastes the most time, because nothing you started is holding it. AirPlay Receiver binds 5000 and 7000, so a Flask app on its default port fails on a machine where the developer installed nothing. `lsof` gives it away immediately — the owner is ControlCenter, not `node` or `python`. Turn it off in System Settings → General → AirDrop & Handoff, or move your app. Port 9323 is Playwright's, and it behaves better than people expect — which is worth knowing before you go hunting for a PID. The HTML reporter serves the report there by default, but it treats 9323 as a _preferred_ port, not a required one. Reading `playwright-core/lib/server/utils/httpServer.js` on Playwright 1.57: ```js httpServer.js if (options.preferredPort) { try { await this._tryStart(options.preferredPort, host); } catch (e) { if (!e || !e.message || !e.message.includes('EADDRINUSE')) throw e; await this._tryStart(void 0, host); } } ``` It catches `EADDRINUSE` and retries with no port at all, letting the OS pick a free one. So an open report in a browser tab does **not** break the next run — you just get the report on a different port than you expected. What actually stops a Playwright run is the `webServer` block in the config, and it is a different message with a different fix: ```text webserver.txt http://localhost:3000 is already used, make sure that nothing is running on the port/url or set reuseExistingServer:true in config.webServer. ``` The error tells you the fix: if the thing on that port is your own dev server, which is usually the case, `reuseExistingServer: true` makes Playwright use it instead of trying to start a second one. ## The address matters as much as the port `EADDRINUSE` is about an **address** and a port, not a port alone — which is why the message names one, like `0.0.0.0:3000` or `::1:9323`. That distinction is real: `127.0.0.1:3000` (IPv4 loopback) and `[::1]:3000` (IPv6 loopback) are different addresses. A server bound to one does not necessarily collide with a server bound to the other, and a server bound to `0.0.0.0` collides with both because it claims every IPv4 interface on the machine. It explains two confusing cases: - `lsof -i :3000` shows nothing, but your server still fails — look at the exact address in the error, and check the other protocol family. - Two servers on "the same port" happily coexist, until a third one binds `0.0.0.0` and everything breaks. Use `lsof -nP -iTCP:3000 -sTCP:LISTEN` to see the address as well as the PID: `-n` and `-P` skip the DNS and service-name lookups, so you get `*:3000` instead of the `TCP *:hbci` in the output above — `hbci` is just `/etc/services` renaming port 3000 at you. ## Free the port on macOS and Linux Find the process holding the port with `lsof` (on Linux `ss -ltnp` or `fuser 3000/tcp` work too): ```bash MAKINOTE$ lsof -i :3000 COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME node 38091 xabier.lameirocardam 24u IPv6 0xee02297dd086ebc1 0t0 TCP *:hbci (LISTEN) ``` Then terminate it. Try a clean `kill ` first, and fall back to `kill -9 ` only if the process ignores it: ```bash MAKINOTE$ kill -9 38091 ``` ## Free the port on Windows List the PID bound to the port, then kill it: ```powershell netstat -ano | findstr :3000 taskkill /PID 38091 /F ``` ## One command on any OS If you would rather not look up PIDs, `kill-port` does it for you — no install needed: ```bash npx kill-port 3000 ``` ## Or just use another port Often the fastest fix is not to fight for the port at all: ```bash PORT=3001 npm run dev # apps that read process.env.PORT next dev -p 3001 # Next.js ``` ## Inside Docker If the server runs in a container, the host port is held by the port mapping, not by a local PID — `lsof` will point at `com.docker`. Stop the container (`docker ps` then `docker stop `) or change the mapping (`-p 3001:3000`). Docker phrases it differently enough that people do not connect it to `EADDRINUSE`: ```text docker.txt Error response from daemon: driver failed programming external connectivity on endpoint web: Bind for 0.0.0.0:3000 failed: port is already allocated ``` `port is already allocated` is Docker's own bookkeeping — the daemon knows it already published that port — whereas `bind: address already in use` is the kernel refusing, same as everywhere else. The first can survive a container that has already exited; `docker ps -a` and removing the stale container clears it, and `docker compose down` is the blunt version. ## When none of this works Two cases where the port genuinely is not yours to take: - **Ports below 1024 need privileges.** Binding 80 or 443 as a normal user fails with `EACCES`, not `EADDRINUSE`. Different error, different fix — if you are reading `EACCES`, stop looking for a process to kill. - **The port is in `TIME_WAIT`.** After a socket closes, the OS holds the pair for up to a couple of minutes. `lsof -i :3000` shows a connection but no listener, and there is nothing to kill. Servers avoid this with `SO_REUSEADDR`, which most frameworks set for you; waiting also works. --- # How to fix Minified React error #425 (hydration mismatch) - URL: https://xabierlameiro.com/blog/error/uncaught-error-minified-react-error - Author: Xabier Lameiro - Category: Error · Tags: hydration, ssr - Summary: Fix Minified React error #425 ("Text content does not match server-rendered HTML"): every common cause of a hydration mismatch — dates, random values, locale formatting, browser-only APIs, invalid HTML — and the right fix for each, with Next.js examples. # How to fix Minified React error #425 (hydration mismatch) Published: 01-05-2023 ## Quick answer React error #425 — **"Text content does not match server-rendered HTML"** — is a **hydration mismatch**: the server rendered one value and the browser rendered a different one, so React throws away the server markup for that subtree. The fix depends on _why_ they differ: - The value legitimately differs (a date, a time) → add `suppressHydrationWarning` (Fix 1). - The value only exists in the browser (`window`, `localStorage`) → render it after mount (Fix 2). - The whole component is client-only → load it with `next/dynamic` and `ssr: false` (Fix 3). - Your HTML is invalid (a `
` inside a `

`) → fix the nesting (Fix 4). On React 19 the full production error reads: `Uncaught Error: Minified React error #425; visit https://react.dev/errors/425 for the full message or use the non-minified dev environment for full errors and additional helpful warnings.` On React 18 the same error pointed at `https://reactjs.org/docs/error-decoder.html?invariant=425` — that URL still works, it 308-redirects to the react.dev page. ## What "Minified React error #425" actually means In a production build React ships **minified** error messages to keep the bundle small, so instead of a sentence you get a number and a decoder link (`react.dev/errors/425`, or `?invariant=425` on React 18). #425 decodes to **"Text content does not match server-rendered HTML"**. That only happens with server-side rendering (Next.js, Remix, `renderToString`): React renders the HTML once on the server, sends it to the browser, and then **hydrates** — it reuses that HTML and only attaches the event handlers, expecting the client's first render to produce _exactly_ the same output. When a piece of text comes out different, hydration for that subtree fails and you get #425. Because the message is minified, the single most useful debugging step is to reproduce it in a **development build** — there React prints the full message _and points at the exact element_, which turns guesswork into a one-line fix (see "How to see the real error message" below). ## Why hydration mismatches happen Every #425 comes down to _server output ≠ first client output_. The usual culprits: | Cause | Why it differs | | --------------------------------------- | -------------------------------------------------------------- | | `new Date()` / `toLocaleString` | Server clock/timezone ≠ browser clock/timezone | | `Math.random()`, `Date.now()`, `uuid()` | A new value on each render, server and client never agree | | Locale / number / currency formatting | Server locale ≠ the user's browser locale | | `window`, `localStorage`, `navigator` | Undefined on the server, present in the browser | | Invalid HTML nesting | The browser "fixes" your HTML, so the DOM ≠ what React rendered | | Browser extensions | They mutate the DOM before React hydrates | ![Diagram of a hydration mismatch: the server renders the time as 14:32, the browser re-renders it as 14:33 while hydrating, the text does not match and React throws error #425.](/posts/hydration-mismatch.png) The example below is the classic case: a live clock. ## The code that caused the error ```tsx DateAndHour.tsx const DateAndHour = () => { const { locale } = useRouter(); const [date, setDate] = React.useState(new Date()); const day = date.toLocaleDateString(locale, { weekday: 'short' }); const dayNumber = date.toLocaleDateString(locale, { day: 'numeric' }); const month = date.toLocaleDateString(locale, { month: 'short' }); const hour = date.toLocaleTimeString(locale, { hour: 'numeric', minute: 'numeric' }); React.useEffect(() => { const interval = setInterval(() => { setDate(new Date()); }, 60000); return () => clearInterval(interval); }, []); return (

{day} {dayNumber} {month} {hour}
); }; ``` The server renders the time for _its_ clock and timezone; a second later the browser renders _its_ own — the text differs and React raises #425. ## Fix 1 — `suppressHydrationWarning` for legitimate text differences When the difference is real and expected (a clock, a relative "2 minutes ago"), tell React to accept it on that element with [`suppressHydrationWarning`](https://react.dev/reference/react-dom/components/common#common-props). It only silences a **text** difference **one level deep** — it is a scalpel, not a blanket, so don't sprinkle it everywhere. ```tsx DateAndHour.tsx mark=14,15,16,17 const DateAndHour = () => { const { locale } = useRouter(); const [date, setDate] = React.useState(new Date()); const day = date.toLocaleDateString(locale, { weekday: 'short' }); const dayNumber = date.toLocaleDateString(locale, { day: 'numeric' }); const month = date.toLocaleDateString(locale, { month: 'short' }); const hour = date.toLocaleTimeString(locale, { hour: 'numeric', minute: 'numeric' }); React.useEffect(() => { const interval = setInterval(() => setDate(new Date()), 60000); return () => clearInterval(interval); }, []); return (
{day} {dayNumber} {month} {hour}
); }; ``` ## Fix 2 — Render browser-only values after mount If the value simply does not exist on the server (`window`, `localStorage`, `matchMedia`), render a stable placeholder first and swap in the real value **after** the component mounts. The server and the first client render now agree, and the browser value appears on the next paint. ```tsx useMounted.tsx // Render the same thing on the server and on the first client render, // then reveal the browser-only value once we know we are on the client. const useMounted = () => { const [mounted, setMounted] = React.useState(false); React.useEffect(() => setMounted(true), []); return mounted; }; const Theme = () => { const mounted = useMounted(); if (!mounted) return null; // same output on server and first client render return {localStorage.getItem('theme')}; }; ``` ## Fix 3 — Load client-only components with `next/dynamic` When a whole component can never render on the server (a chart that reads `window`, a map, an editor), skip SSR for it entirely instead of guarding every line: ```tsx dynamic.tsx import dynamic from 'next/dynamic'; const ClientOnlyChart = dynamic(() => import('./Chart'), { ssr: false }); ``` ## Fix 4 — Fix invalid HTML nesting This one surprises people because there is no date or random value in sight. If you nest a block element inside a `

` (or an `` inside an ``, or a `

` inside a `` without a ``), the browser silently _repairs_ the HTML on parse. React then hydrates against a DOM that no longer matches what it rendered — and you get #425. There is no attribute to suppress this: **make the HTML valid** (a `

` may only contain inline content). ## How to see the real error message Never debug #425 from the minified number. Reproduce it where React talks to you: - **Next.js:** run `next dev` — development is never minified. - **Production build locally:** `next build && next start`, but read the message in the browser console where React prints the full text and a diff of the mismatched element. React logs something like _"Warning: Text content did not match. Server: '14:32' Client: '14:33'"_, pointing at the exact node. From there the cause is usually obvious. ## #418, #422, #423, #425 — the hydration error family If your searches turned up neighbouring numbers, they are the same problem seen from different angles. What separates them is how far React had to fall back once hydration failed — these are the messages verbatim from React's own `scripts/error-codes/codes.json`: | Error | Meaning | | ----- | ---------------------------------------------------------------------------------------------------- | | #418 | Hydration failed because the server rendered HTML didn't match the client; the tree is regenerated | | #422 | React recovered by client-rendering from the nearest Suspense boundary | | #423 | React recovered by client-rendering the entire root | | #425 | Text content does not match server-rendered HTML | The distinction is worth knowing because it tells you how much of the page you just lost. #425 costs you one text node; #423 means React threw away the server HTML for the **whole root** and re-rendered everything in the browser, which is the SSR benefit gone. The fix is always the same discipline: run a dev build, find the value that differs between server and client, and reconcile it with one of the four fixes above. You can read more about the attribute in the [React documentation](https://react.dev/reference/react-dom/components/common#common-props 'React Documentation'). --- # Lighthouse report automation - URL: https://xabierlameiro.com/blog/javascript/lighthouse-reporting-automation - Author: Xabier Lameiro - Category: Javascript · Tags: node - Summary: Automation of Lighthouse reporting using playwright and the lightouse api on a deployed website in a real example # Lighthouse report automation Published: 01-24-2023 An easy way to have automated Lighthouse reports is to drive Google's [Lighthouse](https://github.com/GoogleChrome/lighthouse 'Lighthouse repository') from a script: run the audit against a site and save the results so you can compare them with other runs. Worth being precise about the name, because the two get conflated: this uses **Lighthouse**, the auditing engine. [Lighthouse CI](https://github.com/GoogleChrome/lighthouse-ci) is a different project — the `@lhci/cli` and `@lhci/server` packages, which exist to store runs and compare them against a budget. If you want assertions and a history server rather than a script you control, that is the one to reach for. You can see the improvements in performance, accessibility, SEO, etc. of your website. You can even add new rules with custom plugins, for example, to check that no html has been added that does not comply with the accessibility rules. In a future post the idea is to create a lighthouse plugin from scratch to check some unwritten SEO "rules" in each of my posts. To always have a current snapshot of the sitemap of my site I use playwright to check the sitemap, have the link tree and create a flowchart using Treant.js as you can see here [lighthouse report](https://lighthouse.xabierlameiro.com 'Lighthouse report example'). ## How did I do it? ## 1. Get all urls from your sitemap ```javascript main.js const browser = await chromium.launch(); const page = await browser.newPage(); await page.goto('https://xabierlameiro.com/sitemap.xml'); // get site map inside a body const sitemap = await page.locator('body').innerHTML(); // get all urls inside sitemap - Fixed: Use safer regex pattern const urls = sitemap.match(/([^<]+)<\/loc>/g)?.map((url) => url.replace(/<\/?loc>/g, '')) || []; // split urls in locales bearing in mind that 'en' is the default locale and it's not included in the url let locales = urls.reduce((acc, url) => { let locale = url.split('/')[3]; if (locale !== 'gl' && locale !== 'es') { locale = 'en'; } if (!acc[locale]) { acc[locale] = []; } acc[locale].push(url); return acc; }, {}); const index = locales.en.indexOf('https://xabierlameiro.com'); locales.en[index] = 'https://xabierlameiro.com/home'; await browser.close(); ``` ## 2. Run lighthouse on each of the urls and save the report. ```javascript main.js for (const url of locales[lang]) { // Report const result = await launchChromeAndRunLighthouse( url === 'https://xabierlameiro.com/home' ? 'https://xabierlameiro.com' : url, options ); let fileName = url.split('/').pop(); if (fileName === 'es' || fileName === 'gl') { fileName = 'home'; } // write report in output folder fs.writeFileSync(lang === 'en' ? `output/${fileName}.html` : `output/${lang}/${fileName}.html`, result.report); } ``` ## 3. Create an organisation chart using Treant.js > Example of the first level of the tree only ```javascript main.js const chart = { chart: { container: '#OrganiseChart-big-commpany', levelSeparation: 40, siblingSeparation: 20, subTeeSeparation: 30, rootOrientation: 'NORTH', nodeAlign: 'BOTTOM', connectors: { type: 'step', style: { 'stroke-width': 2, }, }, node: { HTMLclass: 'big-commpany', }, }, }; ``` > All the code is available in my [github](https://github.com/xabierlameiro/lighthouse-report/blob/master/main.js 'Link to github repository') repository ## Run your script and deploy the files to a static server like Vercel. ```bash node main.js ``` If you want to see the results just click on "Lightouse" in the top navigation bar. --- # Continuous Integration with Github Actions workflow - URL: https://xabierlameiro.com/blog/nextjs/continuous-integration-with-github-actions-workflow - Author: Xabier Lameiro - Category: Nextjs · Tags: github-actions, ci, devops - Summary: Set up CI/CD for a Next.js project with GitHub Actions: lint, test and build on every push, then deploy to Vercel. The workflow YAML explained step by step. # Continuous Integration with Github Actions workflow Published: 05-09-2023 Every project is different and so are its priorities, but they all have one thing in common: they need to be tested and deployed. You can do that by hand, but it gets repetitive and tedious fast — which is exactly what continuous integration and continuous deployment exist to remove. Here I use [GitHub Actions](https://docs.github.com/en/actions/using-workflows 'Link to github Actions') to automate deploying this project to [Vercel](https://vercel.com/ 'Link to vercel'), plus the checks that run before it: testing, linting, type-checking and documentation generation. ## Design The part that takes the most thought is the pipeline itself — which tasks run, and in what order. I sketched the flow in Excalidraw and iterated until one shape stuck. Then it is mechanical: create a `.github/workflows/` folder and drop your `.yml` files in it. I keep two, `pre-deploy.yml` and `post-deploy.yml`, and instead of explaining YAML syntax I will explain what each job does. `pre-deploy` runs four jobs in parallel — cancel-redundant-runs, linting, testing and docs — and only if they all pass does it version and deploy to pre-production. I then test the feature or bugfix manually against that preview. If it holds up, deploying to production is just a pull request from `dev` to `master`. But the pipeline does not end at the merge: once it is live, `post-deploy` runs a Lighthouse pass and publishes a page-by-page performance report. Splitting it this way keeps the fast gates blocking (nothing ships broken) and the slow, live-site checks out of the critical path. Here it is graphically: This is a snippet of what the pre-deploy workflow looks like (Node 22, npm — update the versions to match your project, this is the kind of thing that goes stale): ```yaml pre-deploy.yml name: Workflow Pre-Deploy on: push: branches: [dev] # Cancels superseded runs. Replaces a third-party action that needed a token. concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true # Least privilege by default; jobs that need to write raise it themselves. permissions: contents: read jobs: code_checking: name: Linting runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - uses: actions/setup-node@v7 with: node-version: 22 cache: npm - name: Install dependencies env: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} run: | echo "//npm.pkg.github.com/:_authToken=${NPM_TOKEN}" >> ~/.npmrc echo "@xabierlameiro:registry=https://npm.pkg.github.com/" >> ~/.npmrc npm ci --legacy-peer-deps - name: Linter run: npm run lint ``` Full workflow [in my github](https://github.com/xabierlameiro/the-last-dance/actions/workflows/pre-deploy.yml 'in my github') ## Five things worth doing from day one - **Cache the install.** `actions/setup-node` with `cache: npm` restores `node_modules`-worth of downloads between runs and cuts minutes off every job. Point it at your real lockfile. - **Cancel redundant runs with `concurrency:`, not an action.** Pushing three times in a row should not run three full pipelines. This used to be a `styfle/cancel-workflow-action` step here, and I removed it from my own pipeline for two reasons. It was pinned by a **mutable tag**, which GitHub's [hardening guide](https://docs.github.com/en/actions/reference/security/secure-use) tells you not to do for third-party actions — a tag can be repointed at new code, which is exactly how the `tj-actions/changed-files` compromise reached thousands of repositories in March 2025. And it needed `access_token`, so I was handing a PAT with write access to five other repositories to a third party on every push. The native `concurrency:` key does the same job with no token and no third-party code. - **Set `permissions:` explicitly.** Without it the `GITHUB_TOKEN` gets the repository default, which is broader than a linting job needs. `contents: read` at the top of the file, raised per job where something genuinely has to write, is the whole change. - **Keep the action majors current.** `actions/checkout@v4` and `setup-node@v4` still run, but they declare the Node 20 action runtime, which GitHub has been retiring from the runners. `@v7` is the current major for both and runs on Node 24. - **Keep secrets in secrets.** The registry token comes from `${{ secrets.NPM_TOKEN }}`, never from the file. If a token ever lands in a committed `.npmrc`, rotate it — the runner reads it from the environment, not the repo. ## Results Each report has its own repository on GitHub, and that is where the output of every run is published: - [Unit test report](https://coverage.xabierlameiro.com/ 'Link to Unit test report') - [End to end testing report](https://e2e.xabierlameiro.com/ 'Link to End to end testing report') - [Performance report](https://performance.xabierlameiro.com/ 'Link to Performance report') - [Documentation](https://docs.xabierlameiro.com/ 'Link to Documentation') - [Component Library](https://storybook.xabierlameiro.com/ 'Link to Component Library') ## Conclusions Every time I add a component in React or a page in Next.js, a battery of tests checks that I have not broken the work already there — which only works because you are the one who has to write the tests for the delicate, critical parts. The pipeline does not invent coverage; it enforces the coverage you decided to have. > You can find all this code in my github repository, and if you liked it you can help me with a [⭐️](https://github.com/xabierlameiro/the-last-dance 'Link to my github'). Thanks! --- # Get a GitHub repo's star count without hitting rate limits - URL: https://xabierlameiro.com/blog/nextjs/counter-for-github-stars-repository - Author: Xabier Lameiro - Category: Nextjs · Tags: node - Summary: Read a GitHub repository's star count from stargazers_count, understand the 60 vs 5,000 requests-per-hour limits, and cache the call so a live counter costs one API request an hour. # Get a GitHub repo's star count without hitting rate limits Published: 01-11-2023 ## Quick answer The star count of any public repository is one field in one endpoint, and you do not need a token to read it: ```bash stars.sh curl -s https://api.github.com/repos/vercel/next.js | jq -r .stargazers_count ``` `stargazers_count` is the number. The endpoint returns the whole repository object, so `jq` is only there to pull one field out of it. That is the whole answer if you are scripting. The rest of this post is the part that bites when you put it on a website: **60 requests per hour unauthenticated, 5,000 with a token**, and a counter that calls the API on every page view will burn through either. ## The field, and the ones people confuse it with The repository object carries several counts that look interchangeable and are not: | Field | What it counts | | -------------------- | -------------------------------------------------------------------------- | | `stargazers_count` | Stars. This is the number shown on the repo page | | `watchers_count` | Historically a duplicate of the star count in this endpoint, not "watching" | | `subscribers_count` | People actually watching for notifications | | `forks_count` | Forks | If you have ever wondered why `watchers_count` matches your star count exactly, that is why — the field name outlived the change in what starring means. Use `stargazers_count` and be explicit about it. ## The rate limits, which is what actually decides your design GitHub's REST API allows [60 requests per hour unauthenticated and 5,000 per hour with a personal access token](https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api). Two details matter more than the numbers: **The unauthenticated limit is per IP address.** On a laptop that is your quota. On a cloud host, a CI runner or anything behind a shared NAT, you are sharing those 60 requests an hour with every other tenant on the address — which is why the same script that worked locally starts returning 403 the moment it runs in CI. **Reading a public repo still counts.** There is no free tier for "just a number". Every call to `/repos/{owner}/{repo}` spends one request whether you read one field or all of them. You can check where you stand without spending a request against the limit: ```bash ratelimit.sh curl -s https://api.github.com/rate_limit | jq '.rate' ``` Put those two facts together and the design follows: a public site cannot call the GitHub API per visitor. It needs its own endpoint, a token, and a cache. ## 1 — Your own endpoint, with the token on the server The token belongs in an environment variable read on the server. A token shipped to the browser is a token published: ```ts github-stars.ts import { NextApiRequest, NextApiResponse } from 'next'; import { Octokit } from 'octokit'; const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN }); const REPOSITORY = { owner: 'xabierlameiro', repo: 'the-last-dance', }; type GitHubStarsResponse = number | { statusCode: number; message: string }; export default async function handler(_req: NextApiRequest, res: NextApiResponse) { try { const { data: { stargazers_count }, } = await octokit.rest.repos.get(REPOSITORY); res.setHeader('Cache-Control', 'public, s-maxage=3600, stale-while-revalidate=86400'); res.status(200).json(stargazers_count); } catch (err: unknown) { console.error('GitHub stars API Error:', err); res.setHeader('Cache-Control', 'no-store'); res.status(500).json({ statusCode: 500, message: 'Internal server error' }); } } ``` Two things in there are corrections to what this post used to publish, and both are worth stating plainly. **`catch (err: unknown)`, not `catch (err: Error)`.** The earlier version of this post annotated the catch variable as `Error`, which is not legal TypeScript — the compiler rejects it with TS1196, _"Catch clause variable type annotation must be `any` or `unknown` if specified"_. It is a hard error, not a strictness setting: anything can be thrown, so the compiler refuses to let you claim otherwise. Narrow inside the block if you need the message. **Do not forward the upstream error message to the client.** The old version returned `message: err.message` straight from Octokit. That leaks: under rate limiting GitHub answers `API rate limit exceeded for user ID 12345`, which discloses the account id behind the token, and on a revoked token it answers `Bad credentials`, which turns your public endpoint into a live oracle for whether the PAT is still working. Log the detail server-side, return a flat message. ## 2 — The cache is the whole point The `Cache-Control` header above is what makes the counter free: ```text cache-control.txt public, s-maxage=3600, stale-while-revalidate=86400 ``` `s-maxage=3600` tells the CDN to keep the response for an hour, so a thousand visitors in that hour produce **one** call to GitHub — 1 of your 5,000, not 1,000. `stale-while-revalidate=86400` is the part people skip: for a day after the response goes stale, the edge keeps serving the last known number while it refreshes in the background. If GitHub is down or you are rate-limited, the widget shows a slightly old count instead of an error. That trade is easy here because star counts are not urgent. Nobody notices a number that is fifty minutes old; everybody notices a broken widget. ## 3 — The component Fetching in a `useEffect` and encoding states as magic numbers is what the original version of this post did, and it aged badly. `0` meant loading, `-1` meant error, and there was a branch for `-2` that nothing ever assigned — dead code rendering a state that could not happen. A data-fetching library gives you the three states as actual states: ```ts useGithubStars.ts import useSWR from 'swr'; const url = `${process.env.NEXT_PUBLIC_DOMAIN}/api/github-stars`; const useGithubStars = (): { data: number | undefined; error: Error | undefined; loading: boolean } => { const { data, error, isLoading } = useSWR(url, fetchGithubStars, { fallbackData: 0, dedupingInterval: 5000, }); return { data, error, loading: isLoading }; }; ``` `dedupingInterval` matters more than it looks: without it, several components asking for the same key mount and each fires its own request. With it, they share one. The component then renders loading, error and success without inventing sentinel values: ```tsx StarCounter.tsx const StarCounter = () => { const { data, error, loading } = useGithubStars(); const { formatMessage: f } = useIntl(); return ( ); }; ``` The `aria-label` is not decoration. Without it this link's entire content is a star icon and a bare number — and while the count is loading, a spinner and nothing else. An accessibility check on this site flagged it as a link with no accessible name, announced to a screen reader as "link" with no destination. ## If you only need the number once All of the above is for a live counter. If the count only has to be right at build time, skip the endpoint entirely and read it in `getStaticProps`: one request per build, no runtime quota, no cache to reason about. The reason this site does not is that the value has to update between deploys and the blog is statically generated — the endpoint exists to give a static page one live number. ## The mistakes worth remembering - `catch (err: Error)` does not compile. Use `unknown`. - Never return the upstream error message from a public endpoint. - 60 requests an hour is per IP, and shared IPs share it. - Cache at the edge, not in a module variable — serverless functions do not keep it. - `stale-while-revalidate` is what turns an outage into a slightly stale number. Right now you only have to look up to see the result, the counter is highlighted with a star. Please if you think this content has helped you or you like it, please give me your star. 🤩 --- # Dark mode in Next.js without the flash of the wrong theme - URL: https://xabierlameiro.com/blog/nextjs/dark-theme - Author: Xabier Lameiro - Category: Nextjs · Tags: css - Summary: Dark mode in Next.js with CSS variables: resolve the theme before the first paint, persist the choice, and set color-scheme so scrollbars and form controls follow it. # Dark mode in Next.js without the flash of the wrong theme Published: 02-03-2023 ## Quick answer A dark theme in Next.js needs three things, and CSS variables are only the first one: - **Define the palette per theme** — one `[data-theme='dark']` block, one `[data-theme='light']` block, with the tokens redefined in each. - **Resolve the theme before the first paint** — a small synchronous `