<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Waseem Anjum</title>
    <description>The latest articles on DEV Community by Waseem Anjum (@fabwaseem).</description>
    <link>https://dev.to/fabwaseem</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1255115%2F67bc77d7-a192-4c19-9547-69041c151ed0.png</url>
      <title>DEV Community: Waseem Anjum</title>
      <link>https://dev.to/fabwaseem</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/fabwaseem"/>
    <language>en</language>
    <item>
      <title>How to Test an MCP Server: Vitest, In-Memory Transport, Streamable HTTP and the Inspector CLI</title>
      <dc:creator>Waseem Anjum</dc:creator>
      <pubDate>Sat, 31 Oct 2026 06:05:29 +0000</pubDate>
      <link>https://dev.to/fabwaseem/how-to-test-an-mcp-server-vitest-in-memory-transport-streamable-http-and-the-inspector-cli-17p9</link>
      <guid>https://dev.to/fabwaseem/how-to-test-an-mcp-server-vitest-in-memory-transport-streamable-http-and-the-inspector-cli-17p9</guid>
      <description>&lt;p&gt;&lt;em&gt;Cross-posted from my blog. The original, with any updates, lives on &lt;a href="https://fabwaseem.com/blog/test-mcp-server" rel="noopener noreferrer"&gt;fabwaseem.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Testing an MCP server&lt;/strong&gt; means checking three layers: the pure functions behind your tools, the MCP contract (tool list, schemas, results and errors), and the transport your clients actually use. You can cover all three with Vitest and the official TypeScript SDK, without starting an AI client, and the whole suite below runs in about 300 ms.&lt;/p&gt;

&lt;p&gt;I wrote this while building &lt;a href="https://fabwaseem.com/projects/benchline" rel="noopener noreferrer"&gt;Benchline&lt;/a&gt;, my set of browser tools that are also a stateless MCP server. Every tool there is a pure function first and an MCP tool second, and that split is what makes testing easy. Below is a small, complete example server you can copy, the tests I run against it, and the MCP Inspector CLI commands for manual checks. Everything was run with &lt;code&gt;@modelcontextprotocol/sdk&lt;/code&gt; 1.32.2, Vitest 5.0.5 and Node 22.20, and I re-ran the full suite on October 31, 2026 (1.32.2 is still the latest SDK release) with the same result.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Unit-test the logic&lt;/strong&gt; as plain functions. Most bugs live there, and these tests need no MCP at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Contract-test the server in memory&lt;/strong&gt; with &lt;code&gt;InMemoryTransport.createLinkedPair()&lt;/code&gt; and a real SDK &lt;code&gt;Client&lt;/code&gt;: list tools, check schemas, call each tool, and assert on errors.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test the transport once&lt;/strong&gt; over Streamable HTTP: the SDK client connects, &lt;code&gt;initialize&lt;/code&gt; returns 200, wrong &lt;code&gt;Accept&lt;/code&gt; headers get 406, &lt;code&gt;GET&lt;/code&gt; gets 405 on a stateless server.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check by hand&lt;/strong&gt; with &lt;code&gt;npx @modelcontextprotocol/inspector --cli &amp;lt;url&amp;gt; --transport http --method tools/list&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;In SDK 1.32.2, a schema failure and an unknown tool both came back as a tool result with &lt;code&gt;isError: true&lt;/code&gt;, not as a thrown error. Write your assertions so they hold either way.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The server under test
&lt;/h2&gt;

&lt;p&gt;Two read-only tools: &lt;code&gt;slugify&lt;/code&gt;, with a typed output schema, and &lt;code&gt;json_format&lt;/code&gt;, which must report bad input as an error result instead of crashing.&lt;br&gt;
&lt;/p&gt;

&lt;p&gt;```ts title="src/server.ts"&lt;br&gt;
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";&lt;br&gt;
import { z } from "zod";&lt;/p&gt;

&lt;p&gt;export function slugify(input: string) {&lt;br&gt;
  return input.normalize("NFKD").replace(/[\u0300-\u036f]/g, "").toLowerCase()&lt;br&gt;
    .replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;export function createServer() {&lt;br&gt;
  const server = new McpServer({ name: "demo-tools", version: "1.0.0" });&lt;/p&gt;

&lt;p&gt;server.registerTool("slugify", {&lt;br&gt;
    title: "Slugify",&lt;br&gt;
    description: "Turn a title into a URL slug (lowercase ASCII, words joined by hyphens).",&lt;br&gt;
    inputSchema: { text: z.string().min(1).max(500) },&lt;br&gt;
    outputSchema: { slug: z.string() },&lt;br&gt;
    annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },&lt;br&gt;
  }, async ({ text }) =&amp;gt; {&lt;br&gt;
    const slug = slugify(text);&lt;br&gt;
    return { content: [{ type: "text", text: slug }], structuredContent: { slug } };&lt;br&gt;
  });&lt;/p&gt;

&lt;p&gt;server.registerTool("json_format", {&lt;br&gt;
    title: "Format JSON",&lt;br&gt;
    description: "Pretty-print a JSON string with the given indent. Returns an error result for invalid JSON.",&lt;br&gt;
    inputSchema: { json: z.string(), indent: z.number().int().min(0).max(8).default(2) },&lt;br&gt;
    annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },&lt;br&gt;
  }, async ({ json, indent }) =&amp;gt; {&lt;br&gt;
    try {&lt;br&gt;
      return { content: [{ type: "text", text: JSON.stringify(JSON.parse(json), null, indent) }] };&lt;br&gt;
    } catch (e) {&lt;br&gt;
      return { isError: true, content: [{ type: "text", text: &lt;code&gt;Invalid JSON: ${(e as Error).message}&lt;/code&gt; }] };&lt;br&gt;
    }&lt;br&gt;
  });&lt;/p&gt;

&lt;p&gt;return server;&lt;br&gt;
}&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;


The key design choice is that `slugify` is exported on its own. The MCP handler is a thin wrapper, so the logic is tested like any other function.

## Layer 1: unit tests for the tool logic



```ts title="test/unit.test.ts"
import { describe, it, expect } from "vitest";
import { slugify } from "../src/server.ts";

describe("slugify (pure function)", () =&amp;gt; {
  it("lowercases and joins words", () =&amp;gt; expect(slugify("Hello, MCP World!")).toBe("hello-mcp-world"));
  it("strips accents", () =&amp;gt; expect(slugify("Crème Brûlée")).toBe("creme-brulee"));
  it("trims separators", () =&amp;gt; expect(slugify("  --a--  ")).toBe("a"));
});
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Nothing MCP-specific here, and that is the point. In Benchline, each tool folder has a pure &lt;code&gt;lib/&lt;/code&gt; covered by Vitest. The MCP layer only maps inputs and outputs.&lt;/p&gt;
&lt;h2&gt;
  
  
  Layer 2: contract tests with the in-memory transport
&lt;/h2&gt;

&lt;p&gt;The SDK ships &lt;code&gt;InMemoryTransport&lt;/code&gt;, which links a client and a server inside one process. You test the real protocol (JSON-RPC messages, schema validation, result shapes) with no ports and no network.&lt;br&gt;
&lt;/p&gt;

&lt;p&gt;```ts title="test/mcp.test.ts"&lt;br&gt;
import { describe, it, expect, beforeAll, afterAll } from "vitest";&lt;br&gt;
import { Client } from "@modelcontextprotocol/sdk/client/index.js";&lt;br&gt;
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";&lt;br&gt;
import { createServer } from "../src/server.ts";&lt;/p&gt;

&lt;p&gt;let client: Client;&lt;br&gt;
beforeAll(async () =&amp;gt; {&lt;br&gt;
  const [clientT, serverT] = InMemoryTransport.createLinkedPair();&lt;br&gt;
  await createServer().connect(serverT);&lt;br&gt;
  client = new Client({ name: "test", version: "1.0.0" });&lt;br&gt;
  await client.connect(clientT);&lt;br&gt;
});&lt;br&gt;
afterAll(() =&amp;gt; client.close());&lt;/p&gt;

&lt;p&gt;describe("MCP contract (in-memory)", () =&amp;gt; {&lt;br&gt;
  it("lists tools with object schemas and descriptions", async () =&amp;gt; {&lt;br&gt;
    const { tools } = await client.listTools();&lt;br&gt;
    expect(tools.length).toBeGreaterThan(0);&lt;br&gt;
    for (const t of tools) {&lt;br&gt;
      expect(t.name).toMatch(/^[a-z][a-z0-9_]{0,63}$/);&lt;br&gt;
      expect(t.description?.length).toBeGreaterThan(20);&lt;br&gt;
      expect(t.inputSchema.type).toBe("object");&lt;br&gt;
      expect(t.annotations?.readOnlyHint).toBe(true);&lt;br&gt;
    }&lt;br&gt;
    expect(new Set(tools.map((t) =&amp;gt; t.name)).size).toBe(tools.length);&lt;br&gt;
  });&lt;/p&gt;

&lt;p&gt;it("returns structured content that matches the output schema", async () =&amp;gt; {&lt;br&gt;
    const r = await client.callTool({ name: "slugify", arguments: { text: "Hello MCP" } });&lt;br&gt;
    expect(r.isError).toBeFalsy();&lt;br&gt;
    expect(r.structuredContent).toEqual({ slug: "hello-mcp" });&lt;br&gt;
  });&lt;/p&gt;

&lt;p&gt;it("applies schema defaults", async () =&amp;gt; {&lt;br&gt;
    const r = await client.callTool({ name: "json_format", arguments: { json: '{"a":1}' } });&lt;br&gt;
    expect((r.content as any)[0].text).toBe('{\n  "a": 1\n}');&lt;br&gt;
  });&lt;/p&gt;

&lt;p&gt;it("reports bad input as a tool error, not a crash", async () =&amp;gt; {&lt;br&gt;
    const r = await client.callTool({ name: "json_format", arguments: { json: "{oops" } });&lt;br&gt;
    expect(r.isError).toBe(true);&lt;br&gt;
    expect((r.content as any)[0].text).toMatch(/^Invalid JSON/);&lt;br&gt;
  });&lt;/p&gt;

&lt;p&gt;it("rejects arguments that fail the input schema", async () =&amp;gt; {&lt;br&gt;
    const r = await client.callTool({ name: "slugify", arguments: { text: "" } }).catch((e) =&amp;gt; e);&lt;br&gt;
    if (r instanceof Error) expect((r as any).code).toBe(-32602);&lt;br&gt;
    else expect((r as any).isError).toBe(true);&lt;br&gt;
  });&lt;/p&gt;

&lt;p&gt;it("errors on unknown tools", async () =&amp;gt; {&lt;br&gt;
    const r = await client.callTool({ name: "nope", arguments: {} }).catch((e) =&amp;gt; e);&lt;br&gt;
    expect(r instanceof Error ? true : (r as any).isError).toBe(true);&lt;br&gt;
  });&lt;br&gt;
});&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;


What these tests catch:

- **Schema regressions.** Every tool must have an object `inputSchema` and a useful description. A model picks tools by name and description, so an empty description is a real bug.
- **Annotation drift.** If a tool is meant to be read-only, assert `readOnlyHint`. Clients can use annotations to decide when to ask the user for confirmation.
- **Duplicate names** after a refactor.
- **Error shape.** A handler that throws on bad input is worse than one that returns `isError: true` with a clear message, because the model can read the message and retry.

Two results surprised me, so I logged them before writing the assertions. With SDK 1.32.2, calling `slugify` with an empty string (which fails `min(1)`) and calling a tool that doesn't exist both resolved to a result with `isError: true`. The unknown-tool text was `MCP error -32602: Tool nope not found`. Older SDK versions and other server frameworks may throw a JSON-RPC error instead, so the tests accept both shapes.

## Layer 3: the Streamable HTTP transport

Clients like Cursor, Claude Code and VS Code connect over HTTP, so test that path once. This server is stateless: a new `McpServer` and transport per request and no session ID. Benchline's public endpoint is stateless in the same way: no API key and no session.



```ts title="src/http.ts"
import { createServer as createHttpServer } from "node:http";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { createServer } from "./server.ts";

// Stateless Streamable HTTP: a new server + transport per request, no session id.
export function startHttp(port = 0) {
  const http = createHttpServer(async (req, res) =&amp;gt; {
    if (req.url !== "/mcp") { res.writeHead(404).end(); return; }
    if (req.method !== "POST") { res.writeHead(405, { Allow: "POST" }).end(); return; }
    const chunks: Buffer[] = []; for await (const c of req) chunks.push(c as Buffer);
    const body = JSON.parse(Buffer.concat(chunks).toString() || "null");
    const server = createServer();
    const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
    res.on("close", () =&amp;gt; { transport.close(); server.close(); });
    await server.connect(transport);
    await transport.handleRequest(req, res, body);
  });
  return new Promise&amp;lt;{ url: URL; close: () =&amp;gt; void }&amp;gt;((resolve) =&amp;gt;
    http.listen(port, "127.0.0.1", () =&amp;gt; {
      const { port } = http.address() as { port: number };
      resolve({ url: new URL(`http://127.0.0.1:${port}/mcp`), close: () =&amp;gt; http.close() });
    }));
}

if (import.meta.url === `file://${process.argv[1]}`) {
  const { url } = await startHttp(Number(process.env.PORT ?? 3321));
  console.log(`MCP server on ${url}`);
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;




&lt;p&gt;```ts title="test/http.test.ts"&lt;br&gt;
import { describe, it, expect, beforeAll, afterAll } from "vitest";&lt;br&gt;
import { Client } from "@modelcontextprotocol/sdk/client/index.js";&lt;br&gt;
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";&lt;br&gt;
import { startHttp } from "../src/http.ts";&lt;/p&gt;

&lt;p&gt;let srv: Awaited&amp;gt;;&lt;br&gt;
const url = () =&amp;gt; process.env.MCP_URL ? new URL(process.env.MCP_URL) : srv.url;&lt;br&gt;
beforeAll(async () =&amp;gt; { if (!process.env.MCP_URL) srv = await startHttp(); });&lt;br&gt;
afterAll(() =&amp;gt; srv?.close());&lt;/p&gt;

&lt;p&gt;const init = { jsonrpc: "2.0", id: 1, method: "initialize",&lt;br&gt;
  params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "curl", version: "1" } } };&lt;/p&gt;

&lt;p&gt;describe("Streamable HTTP transport", () =&amp;gt; {&lt;br&gt;
  it("works with the official SDK client", async () =&amp;gt; {&lt;br&gt;
    const c = new Client({ name: "http-test", version: "1.0.0" });&lt;br&gt;
    await c.connect(new StreamableHTTPClientTransport(url()));&lt;br&gt;
    const { tools } = await c.listTools();&lt;br&gt;
    expect(tools.length).toBeGreaterThan(0);&lt;br&gt;
    await c.close();&lt;br&gt;
  });&lt;/p&gt;

&lt;p&gt;it("answers initialize without a session id (stateless)", async () =&amp;gt; {&lt;br&gt;
    const r = await fetch(url(), { method: "POST", body: JSON.stringify(init),&lt;br&gt;
      headers: { "content-type": "application/json", accept: "application/json, text/event-stream" } });&lt;br&gt;
    expect(r.status).toBe(200);&lt;br&gt;
    expect(r.headers.get("mcp-session-id")).toBeNull();&lt;br&gt;
  });&lt;/p&gt;

&lt;p&gt;it("rejects a client that does not accept both content types", async () =&amp;gt; {&lt;br&gt;
    const r = await fetch(url(), { method: "POST", body: JSON.stringify(init),&lt;br&gt;
      headers: { "content-type": "application/json", accept: "application/json" } });&lt;br&gt;
    expect(r.status).toBe(406);&lt;br&gt;
  });&lt;/p&gt;

&lt;p&gt;it("returns 405 for GET when there is no SSE stream", async () =&amp;gt; {&lt;br&gt;
    const r = await fetch(url(), { headers: { accept: "text/event-stream" } });&lt;br&gt;
    expect(r.status).toBe(405);&lt;br&gt;
  });&lt;br&gt;
});&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;


All four passed. Facts worth pinning in tests, because a framework upgrade can silently change them:

- `initialize` returned **200** with no `mcp-session-id` header, so clients don't need to track a session.
- A request with `Accept: application/json` only returned **406**. The Streamable HTTP spec requires clients to accept both `application/json` and `text/event-stream`, and some hand-written clients and proxies don't.
- `GET /mcp` returned **405**, which tells clients there is no server-to-client SSE stream on a stateless server.
- In stateless mode, `tools/list` worked without an `initialize` call first. A raw curl got back a 1,120-byte JSON response for the two tools.

Set `MCP_URL` to run the same file against a deployed server: `MCP_URL=https://your-host/mcp npx vitest run test/http.test.ts`. I ran it against the local server started with `node src/http.ts`, and all 4 tests passed.

Response size is worth a test too. Everything in `tools/list` is sent to the model, so it costs context on every conversation. I measured why that matters in [MCP vs API](https://fabwaseem.com/blog/mcp-vs-api).

## Manual checks with the MCP Inspector CLI

The [MCP Inspector](https://github.com/modelcontextprotocol/inspector) has a web UI and a CLI mode. The CLI is handy in a terminal or a CI smoke step (I first ran it with version 2.12.0, and the same commands gave the same output with 2.13.0, the latest release on October 31, 2026):



```bash title="Inspector CLI"
# list tools
npx @modelcontextprotocol/inspector --cli http://127.0.0.1:3321/mcp \
  --transport http --method tools/list

# call one tool
npx @modelcontextprotocol/inspector --cli http://127.0.0.1:3321/mcp \
  --transport http --method tools/call --tool-name slugify --tool-arg 'text=Hello MCP World'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The call printed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"hello-mcp-world"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"structuredContent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"slug"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"hello-mcp-world"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And raw curl, for when you suspect the client:&lt;br&gt;
&lt;/p&gt;

&lt;p&gt;```bash title="curl"&lt;br&gt;
curl -s &lt;a href="http://127.0.0.1:3321/mcp" rel="noopener noreferrer"&gt;http://127.0.0.1:3321/mcp&lt;/a&gt; \&lt;br&gt;
  -H 'content-type: application/json' \&lt;br&gt;
  -H 'accept: application/json, text/event-stream' \&lt;br&gt;
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;


## Running it in CI



```json title="package.json (scripts)"
{
  "scripts": {
    "test": "vitest run",
    "test:remote": "vitest run test/http.test.ts"
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;npm test&lt;/code&gt; runs all three layers. In 3 test files and 13 tests, Vitest reported a duration of 295 ms. Run &lt;code&gt;test:remote&lt;/code&gt; with &lt;code&gt;MCP_URL&lt;/code&gt; set after each deploy as a smoke test. It catches a broken proxy, a missing route or a CDN that strips headers, which is exactly what unit tests can't see.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to test, as a checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Every tool: unit tests for its logic, including edge cases and bad input.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;tools/list&lt;/code&gt;: unique names, object input schemas, descriptions long enough to be useful, expected annotations.&lt;/li&gt;
&lt;li&gt;Each tool through &lt;code&gt;callTool&lt;/code&gt;: one happy path, one error path that returns &lt;code&gt;isError: true&lt;/code&gt; with a readable message.&lt;/li&gt;
&lt;li&gt;Output schemas: &lt;code&gt;structuredContent&lt;/code&gt; matches what you declared.&lt;/li&gt;
&lt;li&gt;Transport: SDK client connects; &lt;code&gt;initialize&lt;/code&gt; status and session header; &lt;code&gt;Accept&lt;/code&gt; handling (406); &lt;code&gt;GET&lt;/code&gt; behavior (405 when stateless).&lt;/li&gt;
&lt;li&gt;Auth, if you have it: no token gets 401 with a &lt;code&gt;WWW-Authenticate&lt;/code&gt; header, and a wrong-audience token is rejected. The &lt;a href="https://fabwaseem.com/blog/mcp-security" rel="noopener noreferrer"&gt;MCP security guide&lt;/a&gt; covers the cases worth testing.&lt;/li&gt;
&lt;li&gt;Size: keep an eye on the &lt;code&gt;tools/list&lt;/code&gt; byte count as you add tools.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you are building your first server, start with &lt;a href="https://fabwaseem.com/blog/build-mcp-server-nextjs" rel="noopener noreferrer"&gt;how to build an MCP server in Next.js&lt;/a&gt; and &lt;a href="https://fabwaseem.com/blog/mcp-server-stateless" rel="noopener noreferrer"&gt;stateless MCP servers&lt;/a&gt;. Then connect it to your editor with &lt;a href="https://fabwaseem.com/blog/add-remote-mcp-server" rel="noopener noreferrer"&gt;how to add a remote MCP server&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  How do I test an MCP server without an AI client?
&lt;/h3&gt;

&lt;p&gt;Use the official SDK's Client class in your test runner. Connect it to your server with InMemoryTransport.createLinkedPair() for fast in-process tests, or with StreamableHTTPClientTransport for the HTTP path, then call listTools and callTool and assert on the results. No model or editor is needed.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is InMemoryTransport in the MCP SDK?
&lt;/h3&gt;

&lt;p&gt;InMemoryTransport, exported from @modelcontextprotocol/sdk/inMemory.js, creates a linked pair of transports inside one process. Connect your server to one end and a Client to the other, and they exchange real JSON-RPC MCP messages without any network, which makes contract tests fast and deterministic.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I use the MCP Inspector CLI?
&lt;/h3&gt;

&lt;p&gt;Run npx @modelcontextprotocol/inspector --cli followed by the server URL, --transport http and a method. For example --method tools/list lists tools, and --method tools/call --tool-name slugify --tool-arg 'text=Hello' calls one. It prints the JSON result, which makes it useful for scripts and CI smoke checks.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should an MCP tool throw or return isError?
&lt;/h3&gt;

&lt;p&gt;Return a result with isError: true and a clear text message for expected failures such as invalid input. The model can read the message and try again. In my tests with SDK 1.32.2, even schema validation failures and unknown tool names came back as isError results.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does my MCP server return 406 Not Acceptable?
&lt;/h3&gt;

&lt;p&gt;The Streamable HTTP transport requires the client to send an Accept header listing both application/json and text/event-stream. A request with only Accept: application/json got 406 from the SDK's server transport in my test. Fix the client or the proxy that rewrites the header.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://fabwaseem.com/blog/test-mcp-server" rel="noopener noreferrer"&gt;fabwaseem.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>typescript</category>
      <category>testing</category>
      <category>ai</category>
    </item>
    <item>
      <title>Free OG Image Generator: Open Graph Image Sizes and Dynamic OG Images in Next.js</title>
      <dc:creator>Waseem Anjum</dc:creator>
      <pubDate>Fri, 30 Oct 2026 07:03:55 +0000</pubDate>
      <link>https://dev.to/fabwaseem/free-og-image-generator-open-graph-image-sizes-and-dynamic-og-images-in-nextjs-2h0k</link>
      <guid>https://dev.to/fabwaseem/free-og-image-generator-open-graph-image-sizes-and-dynamic-og-images-in-nextjs-2h0k</guid>
      <description>&lt;p&gt;&lt;em&gt;Cross-posted from my blog. The original, with any updates, lives on &lt;a href="https://www.fabwaseem.com/blog/free-og-image-generator" rel="noopener noreferrer"&gt;fabwaseem.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;free OG image generator&lt;/strong&gt; turns a title, a subtitle and a few colors into a 1200×630 PNG that link previews can display. I built one you can use right now, with no account and no watermark: &lt;strong&gt;&lt;a href="https://tools.fabwaseem.com/t/og-image-generator" rel="noopener noreferrer"&gt;open the free OG image generator&lt;/a&gt;&lt;/strong&gt;. It exports a ready PNG, gives you a live image URL, and writes the matching Next.js &lt;code&gt;opengraph-image.tsx&lt;/code&gt; file for the template you picked.&lt;/p&gt;

&lt;p&gt;This guide covers what an OG image is, the correct &lt;strong&gt;Open Graph image size&lt;/strong&gt; for every major platform, how to use the tool, how to generate &lt;strong&gt;dynamic OG images in Next.js&lt;/strong&gt; with &lt;code&gt;next/og&lt;/code&gt;, the exact meta tags you need, and how to debug a preview that refuses to update.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is an OG image?
&lt;/h2&gt;

&lt;p&gt;An OG (Open Graph) image is the picture a social network or messaging app shows when someone shares a link. The page declares it with an &lt;code&gt;og:image&lt;/code&gt; meta tag, defined by the Open Graph protocol, and the platform downloads that URL. Without one, the platform shows no image or guesses one from the page, which rarely looks good.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://ogp.me/" rel="noopener noreferrer"&gt;Open Graph protocol&lt;/a&gt; was created by Facebook and is now read by LinkedIn, Slack, Discord, WhatsApp, iMessage and most chat apps. X (Twitter) reads its own &lt;code&gt;twitter:*&lt;/code&gt; tags first and falls back to Open Graph tags when they are missing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Definition:&lt;/strong&gt; an &lt;em&gt;OG image&lt;/em&gt; is the image referenced by a page's &lt;code&gt;og:image&lt;/code&gt; meta tag. A &lt;em&gt;dynamic OG image&lt;/em&gt; is one rendered on request (or at build time) from code, usually with the page title baked in, instead of a static file designed by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is the correct Open Graph image size?
&lt;/h2&gt;

&lt;p&gt;Use 1200×630 pixels, a 1.91:1 aspect ratio, as a PNG or JPG under 5 MB. Facebook recommends at least 1200×630 for high-resolution displays, LinkedIn asks for at least 1200×627 at 1.91:1, and X's large card crops toward 2:1 but displays a 1200×630 image fine. One 1200×630 file covers almost every platform.&lt;/p&gt;

&lt;p&gt;Keep the important text inside a safe zone: leave roughly 60 to 80 px of padding on every side, because some apps crop the edges or round the corners. Large, high-contrast titles survive small mobile previews far better than paragraphs of small text.&lt;/p&gt;

&lt;h2&gt;
  
  
  What OG and Twitter card sizes does each platform use?
&lt;/h2&gt;

&lt;p&gt;Each platform publishes slightly different rules, but they converge on 1.91:1. The table below lists the recommended size and the hard limits from each platform's own documentation, so you can design one image and know where it might be cropped.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Platform&lt;/th&gt;
&lt;th&gt;Recommended size&lt;/th&gt;
&lt;th&gt;Ratio&lt;/th&gt;
&lt;th&gt;Limits from official docs&lt;/th&gt;
&lt;th&gt;Tag read&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Facebook&lt;/td&gt;
&lt;td&gt;1200 × 630&lt;/td&gt;
&lt;td&gt;1.91:1&lt;/td&gt;
&lt;td&gt;Minimum 200 × 200; 600 × 315 for large previews; max 8 MB&lt;/td&gt;
&lt;td&gt;&lt;code&gt;og:image&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LinkedIn&lt;/td&gt;
&lt;td&gt;1200 × 627&lt;/td&gt;
&lt;td&gt;1.91:1&lt;/td&gt;
&lt;td&gt;Minimum 1200 × 627; max 5 MB&lt;/td&gt;
&lt;td&gt;&lt;code&gt;og:image&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;X (summary_large_image)&lt;/td&gt;
&lt;td&gt;1200 × 600 to 1200 × 675&lt;/td&gt;
&lt;td&gt;2:1&lt;/td&gt;
&lt;td&gt;Minimum 300 × 157; max 4096 × 4096; under 5 MB; JPG, PNG, WEBP or GIF&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;twitter:image&lt;/code&gt;, falls back to &lt;code&gt;og:image&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;X (summary)&lt;/td&gt;
&lt;td&gt;144 × 144 or larger&lt;/td&gt;
&lt;td&gt;1:1&lt;/td&gt;
&lt;td&gt;Max 4096 × 4096; under 5 MB&lt;/td&gt;
&lt;td&gt;&lt;code&gt;twitter:image&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Slack&lt;/td&gt;
&lt;td&gt;1200 × 630&lt;/td&gt;
&lt;td&gt;1.91:1&lt;/td&gt;
&lt;td&gt;Unfurls use Open Graph tags&lt;/td&gt;
&lt;td&gt;&lt;code&gt;og:image&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Discord&lt;/td&gt;
&lt;td&gt;1200 × 630&lt;/td&gt;
&lt;td&gt;1.91:1&lt;/td&gt;
&lt;td&gt;Embeds use Open Graph tags&lt;/td&gt;
&lt;td&gt;&lt;code&gt;og:image&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WhatsApp&lt;/td&gt;
&lt;td&gt;1200 × 630&lt;/td&gt;
&lt;td&gt;1.91:1&lt;/td&gt;
&lt;td&gt;Small files load more reliably&lt;/td&gt;
&lt;td&gt;&lt;code&gt;og:image&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Next.js file convention&lt;/td&gt;
&lt;td&gt;1200 × 630&lt;/td&gt;
&lt;td&gt;1.91:1&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;opengraph-image&lt;/code&gt; max 8 MB, &lt;code&gt;twitter-image&lt;/code&gt; max 5 MB (build fails above)&lt;/td&gt;
&lt;td&gt;generates both&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Sources: &lt;a href="https://developers.facebook.com/docs/sharing/webmasters/images/" rel="noopener noreferrer"&gt;Facebook image guidelines&lt;/a&gt;, &lt;a href="https://www.linkedin.com/help/linkedin/answer/a521928" rel="noopener noreferrer"&gt;LinkedIn sharing image requirements&lt;/a&gt;, &lt;a href="https://developer.x.com/en/docs/x-for-websites/cards/overview/summary-card-with-large-image" rel="noopener noreferrer"&gt;X summary card with large image&lt;/a&gt; and the &lt;a href="https://nextjs.org/docs/app/api-reference/file-conventions/metadata/opengraph-image" rel="noopener noreferrer"&gt;Next.js opengraph-image docs&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I use the free OG image generator?
&lt;/h2&gt;

&lt;p&gt;Open the tool, pick a template, type your title, subtitle and author, choose a font and three colors, then click Download PNG. The preview updates as you type and always renders at 1200×630. You can also copy the image URL to use directly in &lt;code&gt;og:image&lt;/code&gt;, or copy the Next.js code.&lt;/p&gt;

&lt;p&gt;Step by step:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Go to &lt;a href="https://tools.fabwaseem.com/t/og-image-generator" rel="noopener noreferrer"&gt;tools.fabwaseem.com/t/og-image-generator&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Choose one of six templates: &lt;strong&gt;Blog post&lt;/strong&gt; (title, dek and byline), &lt;strong&gt;Product / SaaS&lt;/strong&gt; (launch card with a mark), &lt;strong&gt;Minimal&lt;/strong&gt; (type and empty space), &lt;strong&gt;Gradient&lt;/strong&gt; (full-bleed color), &lt;strong&gt;Code snippet&lt;/strong&gt; (editor window) or &lt;strong&gt;Personal / profile&lt;/strong&gt; (name, role and portrait).&lt;/li&gt;
&lt;li&gt;Fill in the title, subtitle and author. For the product and profile templates you can add a logo or avatar URL (HTTPS only; PNG, JPEG, WebP or GIF up to 1.5 MB).&lt;/li&gt;
&lt;li&gt;Pick a font: Inter, Syne, Space Grotesk, Playfair Display, JetBrains Mono or Urbanist.&lt;/li&gt;
&lt;li&gt;Set the background, text and accent colors.&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Download PNG&lt;/strong&gt;, &lt;strong&gt;Copy URL&lt;/strong&gt;, or &lt;strong&gt;Copy code&lt;/strong&gt; for an &lt;code&gt;opengraph-image.tsx&lt;/code&gt; file.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyv0glg9mcj1yhf7f71lb.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyv0glg9mcj1yhf7f71lb.jpg" alt="Six 1200x630 OG image templates from the free generator: blog, product, minimal, gradient, code and profile" width="799" height="296"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The PNG is rendered when its URL is requested and is not stored as a file. Browsers and the CDN may cache the exact URL for a day, so the same card loads fast the next time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Can I use the generated image URL directly in my meta tags?
&lt;/h2&gt;

&lt;p&gt;Yes. The Copy URL button gives you an absolute HTTPS link to &lt;code&gt;/api/og-tool&lt;/code&gt; with your settings in the query string, and that URL always returns a 1200×630 PNG. Paste it into &lt;code&gt;og:image&lt;/code&gt; and &lt;code&gt;twitter:image&lt;/code&gt;. For production sites, downloading the PNG or using the generated Next.js file keeps you independent of a third-party URL.&lt;/p&gt;

&lt;p&gt;Here is the shape of a generated URL (shortened):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://www.fabwaseem.com/api/og-tool?template=blog&amp;amp;title=Ship+the+post+people+actually+share&amp;amp;subtitle=A+readable+1200%C3%97630+card&amp;amp;author=Waseem+Anjum&amp;amp;font=syne
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  How do I generate dynamic OG images in Next.js?
&lt;/h2&gt;

&lt;p&gt;Add an &lt;code&gt;opengraph-image.tsx&lt;/code&gt; file to any App Router segment and default-export a function that returns an &lt;code&gt;ImageResponse&lt;/code&gt; from &lt;code&gt;next/og&lt;/code&gt;. Next.js turns that route into an image and injects the &lt;code&gt;og:image&lt;/code&gt;, &lt;code&gt;og:image:width&lt;/code&gt;, &lt;code&gt;og:image:height&lt;/code&gt;, &lt;code&gt;og:image:type&lt;/code&gt; and &lt;code&gt;og:image:alt&lt;/code&gt; tags into the page head automatically. No meta tags are written by hand.&lt;/p&gt;

&lt;p&gt;This is the pattern from the &lt;a href="https://nextjs.org/docs/app/api-reference/file-conventions/metadata/opengraph-image" rel="noopener noreferrer"&gt;official Next.js docs&lt;/a&gt;, adapted for a blog post route with a dynamic &lt;code&gt;slug&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/blog/[slug]/opengraph-image.tsx&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ImageResponse&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next/og&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;readFile&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:fs/promises&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;join&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:path&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;getPost&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/lib/posts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;alt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Blog post cover&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;630&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;contentType&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image/png&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Image&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;slug&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getPost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;interSemiBold&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;readFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cwd&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;assets/Inter-SemiBold.ttf&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ImageResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;
        &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;100%&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;100%&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;flex&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;flexDirection&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;column&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;justifyContent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;flex-end&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;72&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#0C0C0C&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#F5F5F5&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;flex&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;fontSize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;28&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#8FB4C9&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
          yoursite.com/blog
        &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;flex&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;fontSize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;72&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;lineHeight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1.05&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;marginTop&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;fonts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Inter&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;interSemiBold&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;normal&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;weight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;600&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few facts from the docs worth knowing before you ship it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The default export receives &lt;code&gt;params&lt;/code&gt; as a &lt;strong&gt;Promise&lt;/strong&gt;, so &lt;code&gt;await&lt;/code&gt; it (Next.js 15 and later).&lt;/li&gt;
&lt;li&gt;Generated images are &lt;strong&gt;statically optimized&lt;/strong&gt; at build time and cached, unless they use request-time APIs or uncached data.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ImageResponse&lt;/code&gt; uses Satori and Resvg to turn JSX and CSS into a PNG. Only &lt;strong&gt;flexbox&lt;/strong&gt; and a subset of CSS work; &lt;code&gt;display: grid&lt;/code&gt; does not. Every &lt;code&gt;div&lt;/code&gt; with more than one child needs &lt;code&gt;display: 'flex'&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The &lt;a href="https://nextjs.org/docs/app/api-reference/functions/image-response" rel="noopener noreferrer"&gt;ImageResponse reference&lt;/a&gt; lists a &lt;strong&gt;500 KB maximum bundle size&lt;/strong&gt;, which includes your JSX, CSS, fonts and images. Fonts must be TTF, OTF or WOFF.&lt;/li&gt;
&lt;li&gt;Add a &lt;code&gt;twitter-image.tsx&lt;/code&gt; with the same code if you want a separate X image; otherwise X falls back to &lt;code&gt;og:image&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The free generator writes this file for you. Its output loads the chosen Google Font at render time with a small &lt;code&gt;loadFont&lt;/code&gt; helper, so you can paste it into a segment and run it without adding font files.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which meta tags does an OG image need?
&lt;/h2&gt;

&lt;p&gt;At minimum you need &lt;code&gt;og:title&lt;/code&gt;, &lt;code&gt;og:description&lt;/code&gt;, &lt;code&gt;og:url&lt;/code&gt;, &lt;code&gt;og:type&lt;/code&gt; and &lt;code&gt;og:image&lt;/code&gt; with an absolute HTTPS URL, plus &lt;code&gt;twitter:card&lt;/code&gt; set to &lt;code&gt;summary_large_image&lt;/code&gt; for a large X card. Adding &lt;code&gt;og:image:width&lt;/code&gt;, &lt;code&gt;og:image:height&lt;/code&gt; and &lt;code&gt;og:image:alt&lt;/code&gt; helps platforms render the first share without waiting to download the image.&lt;/p&gt;

&lt;p&gt;If you are not on Next.js, paste this into your &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:type"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"article"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:title"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"Your page title"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:description"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"One sentence that earns the click."&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:url"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"https://example.com/blog/your-post"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:image"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"https://example.com/og/your-post.png"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:image:width"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"1200"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:image:height"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"630"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:image:alt"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"Short description of the image"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"twitter:card"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"summary_large_image"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"twitter:title"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"Your page title"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"twitter:description"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"One sentence that earns the click."&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"twitter:image"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"https://example.com/og/your-post.png"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the Next.js App Router you can set the same values with the Metadata API. Set &lt;code&gt;metadataBase&lt;/code&gt; in your root layout so relative image paths resolve to absolute URLs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/layout.tsx&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Metadata&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Metadata&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;metadataBase&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://example.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;openGraph&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;images&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/og.png&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;630&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;alt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Example&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;twitter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;card&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;summary_large_image&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  How do I test and debug an OG image?
&lt;/h2&gt;

&lt;p&gt;Paste the page URL into the Facebook Sharing Debugger and the LinkedIn Post Inspector, then click scrape again to clear their cache. Open the &lt;code&gt;og:image&lt;/code&gt; URL in a browser to confirm it returns a 200 image. Finally, drop the link into Slack or Discord and check the unfurl. Most broken previews are a relative URL, a blocked crawler or a stale cache.&lt;/p&gt;

&lt;p&gt;Testing tools:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://developers.facebook.com/tools/debug/" rel="noopener noreferrer"&gt;Facebook Sharing Debugger&lt;/a&gt;: shows exactly which tags Facebook read and lets you force a re-scrape.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.linkedin.com/post-inspector/" rel="noopener noreferrer"&gt;LinkedIn Post Inspector&lt;/a&gt;: refreshes LinkedIn's cached preview.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;curl -I https://example.com/og.png&lt;/code&gt;: should return &lt;code&gt;200&lt;/code&gt; and &lt;code&gt;content-type: image/png&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;curl -s https://example.com/page | grep og:image&lt;/code&gt;: confirms the tag is in the server HTML, not added later by client JavaScript.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Common causes and fixes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;th&gt;Likely cause&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;No image at all&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;og:image&lt;/code&gt; is a relative path&lt;/td&gt;
&lt;td&gt;Use an absolute https URL; in Next.js set &lt;code&gt;metadataBase&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Old image keeps showing&lt;/td&gt;
&lt;td&gt;Platform cache&lt;/td&gt;
&lt;td&gt;Re-scrape in the debugger; add a version query like &lt;code&gt;?v=2&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Works in production, not on localhost&lt;/td&gt;
&lt;td&gt;Crawlers cannot reach localhost&lt;/td&gt;
&lt;td&gt;Test on a preview deployment or a tunnel&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Image cropped badly on X&lt;/td&gt;
&lt;td&gt;Ratio far from 2:1 or text near the edges&lt;/td&gt;
&lt;td&gt;Use 1200×630 with 60 to 80 px padding&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Next.js build fails on the image&lt;/td&gt;
&lt;td&gt;File over 8 MB (OG) or 5 MB (Twitter)&lt;/td&gt;
&lt;td&gt;Compress, or generate with code&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;ImageResponse&lt;/code&gt; layout is broken&lt;/td&gt;
&lt;td&gt;Unsupported CSS such as grid&lt;/td&gt;
&lt;td&gt;Use flexbox and &lt;code&gt;display: 'flex'&lt;/code&gt; on parents&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tag missing in page source&lt;/td&gt;
&lt;td&gt;Set in a client component&lt;/td&gt;
&lt;td&gt;Set metadata on the server (Metadata API or file convention)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Static OG image or dynamic OG image: which should I use?
&lt;/h2&gt;

&lt;p&gt;Use a static 1200×630 file for pages that rarely change, such as the home page, and dynamic OG images for content that grows, such as blog posts, docs and product pages. Dynamic images keep every share on-brand automatically, while a static file is the simplest and fastest option for a handful of pages.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Static file&lt;/th&gt;
&lt;th&gt;Dynamic (&lt;code&gt;next/og&lt;/code&gt;)&lt;/th&gt;
&lt;th&gt;Free generator URL&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Setup&lt;/td&gt;
&lt;td&gt;Design once, drop in &lt;code&gt;app/&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Write &lt;code&gt;opengraph-image.tsx&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Copy one URL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Per-page titles&lt;/td&gt;
&lt;td&gt;Manual&lt;/td&gt;
&lt;td&gt;Automatic&lt;/td&gt;
&lt;td&gt;One URL per page&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best for&lt;/td&gt;
&lt;td&gt;Home, landing pages&lt;/td&gt;
&lt;td&gt;Blogs, docs, catalogs&lt;/td&gt;
&lt;td&gt;Quick launches, side projects&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Build impact&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Rendered at build or on request&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The generator sits in the middle: design visually, then either download a static PNG or copy the code to make it dynamic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Is this OG image generator really free?
&lt;/h3&gt;

&lt;p&gt;Yes. It runs in the browser with no account, no sign-up and no watermark on the PNG. You can download images, copy the image URL or copy the Next.js code as often as you like.&lt;/p&gt;

&lt;h3&gt;
  
  
  What size is an OG image in Next.js?
&lt;/h3&gt;

&lt;p&gt;The Next.js docs use &lt;code&gt;size = { width: 1200, height: 630 }&lt;/code&gt; in &lt;code&gt;opengraph-image.tsx&lt;/code&gt;, and &lt;code&gt;ImageResponse&lt;/code&gt; defaults to 1200×630. Image files must stay under 8 MB for &lt;code&gt;opengraph-image&lt;/code&gt; and 5 MB for &lt;code&gt;twitter-image&lt;/code&gt;, or the build fails.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need separate Twitter card images?
&lt;/h3&gt;

&lt;p&gt;Usually not. X reads &lt;code&gt;twitter:image&lt;/code&gt; first and falls back to &lt;code&gt;og:image&lt;/code&gt;. Add &lt;code&gt;twitter:card&lt;/code&gt; with the value &lt;code&gt;summary_large_image&lt;/code&gt; so X shows the large card. Create a separate &lt;code&gt;twitter-image&lt;/code&gt; only if you want a different design for X.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why is my OG image not showing?
&lt;/h3&gt;

&lt;p&gt;The most common reasons are a relative image URL, a page that blocks crawlers, a tag added by client-side JavaScript, or a cached old preview. Use an absolute https URL, check the server HTML and re-scrape in the Facebook Sharing Debugger.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I use WebP or SVG for OG images?
&lt;/h3&gt;

&lt;p&gt;Use PNG or JPG for the widest support. X accepts WEBP, but SVG is not supported by the major platforms, and the Next.js file convention accepts JPG, PNG and GIF for static image files. The free generator always outputs PNG.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does an OG image help SEO?
&lt;/h3&gt;

&lt;p&gt;It does not directly change Google rankings, but it improves click-through when links are shared on social networks and in chat apps. For AI search visibility, pair it with good metadata and structured data, as covered in my &lt;a href="https://www.fabwaseem.com/blog/nextjs-seo-ai-search" rel="noopener noreferrer"&gt;Next.js SEO for AI search guide&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Design every OG image at &lt;strong&gt;1200×630 (1.91:1)&lt;/strong&gt;, under 5 MB, with 60 to 80 px of padding.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;og:image&lt;/code&gt; must be an &lt;strong&gt;absolute HTTPS URL&lt;/strong&gt;; in Next.js, set &lt;code&gt;metadataBase&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;In Next.js, an &lt;code&gt;opengraph-image.tsx&lt;/code&gt; with &lt;code&gt;ImageResponse&lt;/code&gt; from &lt;code&gt;next/og&lt;/code&gt; generates the image and the meta tags for you. Use flexbox only.&lt;/li&gt;
&lt;li&gt;Add &lt;code&gt;twitter:card&lt;/code&gt; = &lt;code&gt;summary_large_image&lt;/code&gt;; X falls back to &lt;code&gt;og:image&lt;/code&gt; for the picture.&lt;/li&gt;
&lt;li&gt;Always re-scrape in the Facebook Sharing Debugger and LinkedIn Post Inspector after changing an image.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Ready to make one? &lt;strong&gt;&lt;a href="https://tools.fabwaseem.com/t/og-image-generator" rel="noopener noreferrer"&gt;Create your free OG image now&lt;/a&gt;&lt;/strong&gt;: pick a template, download the PNG or copy the Next.js code. For more of what I build, see my &lt;a href="https://www.fabwaseem.com/projects" rel="noopener noreferrer"&gt;projects&lt;/a&gt; or head back to the &lt;a href="https://www.fabwaseem.com/" rel="noopener noreferrer"&gt;home page&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.fabwaseem.com/blog/free-og-image-generator" rel="noopener noreferrer"&gt;fabwaseem.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>seo</category>
      <category>webdev</category>
      <category>opengraph</category>
    </item>
    <item>
      <title>Free AI SEO Checker: How to Rank in ChatGPT, Perplexity and AI Overviews</title>
      <dc:creator>Waseem Anjum</dc:creator>
      <pubDate>Fri, 30 Oct 2026 04:10:31 +0000</pubDate>
      <link>https://dev.to/fabwaseem/free-ai-seo-checker-how-to-rank-in-chatgpt-perplexity-and-ai-overviews-22g6</link>
      <guid>https://dev.to/fabwaseem/free-ai-seo-checker-how-to-rank-in-chatgpt-perplexity-and-ai-overviews-22g6</guid>
      <description>&lt;p&gt;&lt;em&gt;Cross-posted from my blog. The original, with any updates, lives on &lt;a href="https://www.fabwaseem.com/blog/ai-seo-checker" rel="noopener noreferrer"&gt;fabwaseem.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;An &lt;strong&gt;AI SEO checker&lt;/strong&gt; tests whether AI answer engines such as ChatGPT search, Perplexity and Google AI Overviews can reach, read and quote a page. I built a free one that runs a GEO audit on any public URL in a few seconds and gives you a score out of 100 plus a fix for every warning: &lt;strong&gt;&lt;a href="https://tools.fabwaseem.com/t/ai-seo-checker" rel="noopener noreferrer"&gt;open the free AI SEO checker&lt;/a&gt;&lt;/strong&gt;. No account needed.&lt;/p&gt;

&lt;p&gt;This guide explains what an AI SEO check covers, what each check means, how the score works, how to run the tool, and what the research and the search engines themselves say about how to rank in ChatGPT and other AI answers.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is an AI SEO checker?
&lt;/h2&gt;

&lt;p&gt;An AI SEO checker is a tool that audits a web page for the signals AI answer engines rely on: whether AI crawlers are allowed in robots.txt, whether the page has a sitemap, llms.txt, structured data, clean metadata and headings, and whether its opening text answers the topic directly. It reports what passes, what fails and how to fix it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Definition:&lt;/strong&gt; &lt;em&gt;AI SEO&lt;/em&gt;, also called &lt;em&gt;GEO&lt;/em&gt; (generative engine optimization) or &lt;em&gt;AEO&lt;/em&gt; (answer engine optimization), is the practice of making content easy for AI systems to find, understand and cite in generated answers. A &lt;em&gt;GEO audit&lt;/em&gt; is a checklist review of a page against those requirements.&lt;/p&gt;

&lt;p&gt;The term GEO comes from the research paper &lt;a href="https://arxiv.org/abs/2311.09735" rel="noopener noreferrer"&gt;GEO: Generative Engine Optimization&lt;/a&gt; by Aggarwal and colleagues from Princeton and IIT Delhi, published at KDD 2024.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;AI answer engines can only cite pages they are allowed to crawl and can parse. Most failures are technical: blocked crawlers, missing metadata, no structured data, or a page that buries the answer.&lt;/li&gt;
&lt;li&gt;Google says AI Overviews and AI Mode need no special optimization beyond normal SEO: the page must be indexed and eligible for a snippet.&lt;/li&gt;
&lt;li&gt;The GEO paper found that adding citations, quotations and statistics raised visibility in generated answers by up to about 40%, while keyword stuffing did not help.&lt;/li&gt;
&lt;li&gt;Run a free GEO audit at &lt;a href="https://tools.fabwaseem.com/t/ai-seo-checker" rel="noopener noreferrer"&gt;tools.fabwaseem.com/t/ai-seo-checker&lt;/a&gt;, fix the failures first, then the warnings.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How do I rank in ChatGPT and other AI answers?
&lt;/h2&gt;

&lt;p&gt;To rank in ChatGPT search, Perplexity and Google AI Overviews, make sure their crawlers can reach the page, that the page is indexed by the underlying search engine, and that it answers the question clearly in the first lines with sourced facts. Add structured data that matches the visible text, keep titles and headings precise, and publish a sitemap.&lt;/p&gt;

&lt;p&gt;There is no paid placement or secret switch. What the engines and the research agree on:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Source&lt;/th&gt;
&lt;th&gt;What it says&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://developers.google.com/search/docs/appearance/ai-features" rel="noopener noreferrer"&gt;Google: AI features and your website&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;No extra requirements for AI Overviews or AI Mode. The page must be indexed and eligible for a snippet. Allow crawling, use internal links, keep important content in text, and make structured data match visible text.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://platform.openai.com/docs/bots" rel="noopener noreferrer"&gt;OpenAI crawlers&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;OAI-SearchBot is used for ChatGPT search results; GPTBot is for training; ChatGPT-User fetches pages for user actions. Each respects robots.txt separately.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://docs.perplexity.ai/guides/bots" rel="noopener noreferrer"&gt;Perplexity crawlers&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;PerplexityBot indexes pages for Perplexity answers; Perplexity-User fetches on a user's request.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://support.anthropic.com/en/articles/8896518-does-anthropic-crawl-data-from-the-web-and-how-can-site-owners-block-the-crawler" rel="noopener noreferrer"&gt;Anthropic crawlers&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;ClaudeBot and related agents follow robots.txt.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://arxiv.org/abs/2311.09735" rel="noopener noreferrer"&gt;GEO paper (KDD 2024)&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Citing sources, adding quotations and adding statistics gave the largest visibility gains; keyword stuffing did not help.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The practical order is: be crawlable, be indexed, be clear, be citable. The checker covers the first three directly and gives hints for the fourth.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does the free AI SEO checker test?
&lt;/h2&gt;

&lt;p&gt;The checker fetches the URL you enter plus its robots.txt, llms.txt and sitemap, then runs a set of checks in three groups: on-page (HTTPS, response time, title, meta description, canonical, Open Graph, headings, answer-first lead), discovery (JSON-LD, llms.txt, sitemap) and AI crawler rules for nine named bots.&lt;/p&gt;

&lt;p&gt;Every check, with the weight it carries in the score:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;Group&lt;/th&gt;
&lt;th&gt;Weight&lt;/th&gt;
&lt;th&gt;Passes when&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;HTTPS&lt;/td&gt;
&lt;td&gt;Page&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;The final URL after redirects is https&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Response time&lt;/td&gt;
&lt;td&gt;Page&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;HTML arrives in 800 ms or less (warn up to 1,800 ms)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Title&lt;/td&gt;
&lt;td&gt;Page&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;A title of 15 to 65 characters&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Meta description&lt;/td&gt;
&lt;td&gt;Page&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;A description of 50 to 160 characters&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Canonical&lt;/td&gt;
&lt;td&gt;Page&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;An absolute rel=canonical URL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Open Graph&lt;/td&gt;
&lt;td&gt;Page&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;og:title, og:description and og:image present&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Headings&lt;/td&gt;
&lt;td&gt;Page&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;One H1, then H2 and H3 in order without skipped levels&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Answer-first lead&lt;/td&gt;
&lt;td&gt;Page&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;The opening paragraph is a short, direct answer (roughly 40 to 320 characters, 70 words or fewer)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JSON-LD&lt;/td&gt;
&lt;td&gt;Discovery&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;At least one valid JSON-LD block with a schema.org type&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;llms.txt&lt;/td&gt;
&lt;td&gt;Discovery&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;/llms.txt&lt;/code&gt; exists with an H1 and a summary or links&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sitemap&lt;/td&gt;
&lt;td&gt;Discovery&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;A sitemap is found from robots.txt or &lt;code&gt;/sitemap.xml&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AI crawlers&lt;/td&gt;
&lt;td&gt;Crawlers&lt;/td&gt;
&lt;td&gt;2 each&lt;/td&gt;
&lt;td&gt;Each bot's robots.txt rule matches the expected access&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The crawler rows check these user agents: GPTBot, OAI-SearchBot, ChatGPT-User, ClaudeBot, PerplexityBot, Google-Extended, CCBot and Applebot-Extended (expected: allowed), plus Bytespider (expected: blocked, since it is a high-volume scraper rather than a citation crawler). If you deliberately block training bots such as GPTBot or CCBot, that row will fail; the choice is yours, and the tip explains the trade-off.&lt;/p&gt;

&lt;h2&gt;
  
  
  How is the AI search score calculated?
&lt;/h2&gt;

&lt;p&gt;The score is a weighted average out of 100. Each check has a weight, and a pass earns its full weight, a warning earns half, and a fail earns nothing. The total earned is divided by the total possible weight and rounded. Discovery checks such as JSON-LD and llms.txt weigh most because they decide whether an engine understands the page at all.&lt;/p&gt;

&lt;p&gt;So a page that passes everything except a warning on the answer-first lead loses 4 of 104 possible points and scores around 96. That is what this site scored when I tested the homepage: every check passed except the answer-first lead, which the tool flagged with "The opening text is there, but it is not a short direct answer."&lt;/p&gt;

&lt;p&gt;The tool labels the score with a band:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Score&lt;/th&gt;
&lt;th&gt;Band&lt;/th&gt;
&lt;th&gt;What to do&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;85 to 100&lt;/td&gt;
&lt;td&gt;Strong&lt;/td&gt;
&lt;td&gt;Polish warnings, focus on content quality&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;70 to 84&lt;/td&gt;
&lt;td&gt;Good&lt;/td&gt;
&lt;td&gt;Fix the remaining fails and the heaviest warnings&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;50 to 69&lt;/td&gt;
&lt;td&gt;Mixed&lt;/td&gt;
&lt;td&gt;Fix every fail, starting with crawler access and discovery&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Below 50&lt;/td&gt;
&lt;td&gt;Weak&lt;/td&gt;
&lt;td&gt;Start with crawler access, sitemap and metadata&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  How do I run a GEO audit with the tool?
&lt;/h2&gt;

&lt;p&gt;Open the &lt;a href="https://tools.fabwaseem.com/t/ai-seo-checker" rel="noopener noreferrer"&gt;AI SEO checker&lt;/a&gt;, paste a full public URL, and press &lt;em&gt;Check this URL&lt;/em&gt;. In a few seconds you get a score, a pass, warn or fail status for each check, a one-line detail of what was found, and a fix with a link to a longer guide. Fix the fails first, redeploy, and run it again.&lt;/p&gt;

&lt;p&gt;Step by step:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Pick the page that matters most.&lt;/strong&gt; Usually your homepage, a pricing page or your best article. The audit is per URL, so check key templates separately (home, blog post, product page).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Paste the full URL&lt;/strong&gt;, including &lt;code&gt;https://&lt;/code&gt;, into the &lt;em&gt;Page URL&lt;/em&gt; field. Only public http and https addresses are accepted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run the check.&lt;/strong&gt; The tool fetches the page, robots.txt, llms.txt and the sitemap, with an 8-second timeout per request.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read the score and the groups.&lt;/strong&gt; &lt;em&gt;On the page&lt;/em&gt; covers metadata, headings and the opening answer. &lt;em&gt;Discovery&lt;/em&gt; covers JSON-LD, llms.txt and the sitemap. The crawler section lists each AI bot.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fix fails first.&lt;/strong&gt; A blocked OAI-SearchBot or PerplexityBot means those engines cannot read the page at all, which matters more than a long title.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fix warnings next.&lt;/strong&gt; Each row's "How to fix this" link explains the change.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Re-run after deploying&lt;/strong&gt; to confirm the score moved. The tool allows a few runs per visitor every 10 minutes.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  How do I fix the most common AI SEO failures?
&lt;/h2&gt;

&lt;p&gt;The most common failures are a robots.txt that blocks AI search crawlers, a missing llms.txt, no JSON-LD, a missing or duplicate meta description, and a page that opens with filler instead of an answer. Each has a small, specific fix, and most take minutes in a modern framework such as Next.js.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Failure&lt;/th&gt;
&lt;th&gt;Why it matters&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;OAI-SearchBot or PerplexityBot blocked&lt;/td&gt;
&lt;td&gt;That engine cannot fetch the page to cite it&lt;/td&gt;
&lt;td&gt;Allow them in robots.txt; named groups do not inherit &lt;code&gt;*&lt;/code&gt; rules under &lt;a href="https://www.rfc-editor.org/rfc/rfc9309.html" rel="noopener noreferrer"&gt;RFC 9309&lt;/a&gt;, so repeat your disallows in each group&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No llms.txt&lt;/td&gt;
&lt;td&gt;Agents lack a quick map of your site&lt;/td&gt;
&lt;td&gt;Create one with the &lt;a href="https://tools.fabwaseem.com/t/llms-txt-generator" rel="noopener noreferrer"&gt;free llms.txt generator&lt;/a&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No JSON-LD&lt;/td&gt;
&lt;td&gt;Engines must guess what the page is&lt;/td&gt;
&lt;td&gt;Add Article, Organization, Person, Product or FAQPage markup that matches the page (&lt;a href="https://developers.google.com/search/docs/appearance/structured-data/intro-structured-data" rel="noopener noreferrer"&gt;Google's guide&lt;/a&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Weak or missing meta description&lt;/td&gt;
&lt;td&gt;Snippets and summaries pull poor text&lt;/td&gt;
&lt;td&gt;Write 120 to 160 characters that answer the page's main question&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Skipped heading levels&lt;/td&gt;
&lt;td&gt;Structure is harder to parse into sections&lt;/td&gt;
&lt;td&gt;One H1, then H2s, then H3s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No answer in the first paragraph&lt;/td&gt;
&lt;td&gt;Engines quote clear, direct statements&lt;/td&gt;
&lt;td&gt;Open with a 40 to 60 word answer, then the detail&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Slow HTML&lt;/td&gt;
&lt;td&gt;Crawlers may time out or crawl less&lt;/td&gt;
&lt;td&gt;Cache HTML, cut server work, use a CDN&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No sitemap&lt;/td&gt;
&lt;td&gt;New pages are found more slowly&lt;/td&gt;
&lt;td&gt;Publish &lt;code&gt;/sitemap.xml&lt;/code&gt; and list it in robots.txt (&lt;a href="https://www.sitemaps.org/protocol.html" rel="noopener noreferrer"&gt;sitemaps.org&lt;/a&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A robots.txt that allows AI search crawlers while keeping private paths closed looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User-agent: *
Allow: /
Disallow: /admin/

User-agent: OAI-SearchBot
Allow: /
Disallow: /admin/

User-agent: PerplexityBot
Allow: /
Disallow: /admin/

User-agent: Bytespider
Disallow: /

Sitemap: https://example.com/sitemap.xml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a full Next.js implementation of robots, sitemap, metadata and JSON-LD, see my guide to &lt;a href="https://www.fabwaseem.com/blog/nextjs-seo-ai-search" rel="noopener noreferrer"&gt;Next.js SEO for AI search&lt;/a&gt;. For social previews, the &lt;a href="https://tools.fabwaseem.com/t/og-image-generator" rel="noopener noreferrer"&gt;free OG image generator&lt;/a&gt; makes the og:image the checker looks for.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I write content that AI engines cite?
&lt;/h2&gt;

&lt;p&gt;Write each section so it can be lifted out and quoted on its own: a question as the heading, a direct 40 to 60 word answer first, then detail, tables and steps. Cite primary sources, include concrete numbers you can back up, and define terms plainly. The GEO paper found these citation, quotation and statistics changes gave the biggest gains.&lt;/p&gt;

&lt;p&gt;From the &lt;a href="https://arxiv.org/abs/2311.09735" rel="noopener noreferrer"&gt;GEO paper&lt;/a&gt;, in its own benchmark:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;Effect on visibility in generated answers&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cite sources, add quotations, add statistics&lt;/td&gt;
&lt;td&gt;Largest gains, about 30 to 40% on the position-adjusted word count metric&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fluency and easy-to-understand rewrites&lt;/td&gt;
&lt;td&gt;About 15 to 30%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Authoritative, persuasive tone&lt;/td&gt;
&lt;td&gt;No significant improvement&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Keyword stuffing&lt;/td&gt;
&lt;td&gt;Little or no improvement, and worse on Perplexity in their test&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The paper also reports that lower-ranked pages gained most: in one test, citing sources raised visibility for pages ranked fifth in search results by 115%. Treat these as research results on a benchmark, not guarantees, but the direction matches what the engines say: clear, sourced, well-structured text wins.&lt;/p&gt;

&lt;p&gt;A practical writing checklist:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A question-style H2 for every main question people ask.&lt;/li&gt;
&lt;li&gt;A first sentence that answers it without preamble.&lt;/li&gt;
&lt;li&gt;One definition per new term.&lt;/li&gt;
&lt;li&gt;At least one comparison table.&lt;/li&gt;
&lt;li&gt;Numbered steps for any process.&lt;/li&gt;
&lt;li&gt;Links to official documentation for every factual claim.&lt;/li&gt;
&lt;li&gt;An FAQ section with short, self-contained answers, marked up as &lt;a href="https://schema.org/FAQPage" rel="noopener noreferrer"&gt;FAQPage&lt;/a&gt; if it suits the page.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Is AI SEO different from regular SEO?
&lt;/h2&gt;

&lt;p&gt;Mostly no. AI search reuses the foundations of regular SEO: crawlable, indexed, fast pages with accurate metadata and helpful content. The differences are emphasis. AI engines quote passages, so answer-first writing, clear sections, cited facts and structured data matter more, and there are extra crawler user agents to allow in robots.txt.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Area&lt;/th&gt;
&lt;th&gt;Regular SEO&lt;/th&gt;
&lt;th&gt;AI SEO / GEO&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Goal&lt;/td&gt;
&lt;td&gt;Rank a blue link&lt;/td&gt;
&lt;td&gt;Be quoted or cited in an answer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Crawlers&lt;/td&gt;
&lt;td&gt;Googlebot, Bingbot&lt;/td&gt;
&lt;td&gt;Plus OAI-SearchBot, PerplexityBot, ClaudeBot and others&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Content unit&lt;/td&gt;
&lt;td&gt;The page&lt;/td&gt;
&lt;td&gt;The passage or section&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Signals&lt;/td&gt;
&lt;td&gt;Links, relevance, experience&lt;/td&gt;
&lt;td&gt;The same, plus clarity, citations and direct answers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Extra files&lt;/td&gt;
&lt;td&gt;robots.txt, sitemap&lt;/td&gt;
&lt;td&gt;Optionally llms.txt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Measurement&lt;/td&gt;
&lt;td&gt;Rankings, clicks&lt;/td&gt;
&lt;td&gt;Citations, referral traffic from AI tools, Search Console "Web" data&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Google notes that traffic from AI Overviews and AI Mode is counted in Search Console's Performance report under the Web search type, so you measure it there alongside normal search.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Is the AI SEO checker free?
&lt;/h3&gt;

&lt;p&gt;Yes. The &lt;a href="https://tools.fabwaseem.com/t/ai-seo-checker" rel="noopener noreferrer"&gt;AI SEO checker&lt;/a&gt; is free with no account and no email gate. A small rate limit (a few runs per visitor every 10 minutes) keeps it fast and stops abuse.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is a GEO audit?
&lt;/h3&gt;

&lt;p&gt;A GEO audit is a review of a page against what generative engines need: crawler access, indexability, metadata, structured data, llms.txt, a sitemap, clear headings and answer-first content. The checker automates the technical part and returns a score with fixes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I guarantee my site appears in ChatGPT or AI Overviews?
&lt;/h3&gt;

&lt;p&gt;No. No tool or agency can guarantee it. Google states that meeting every requirement does not guarantee crawling, indexing or serving. You can remove the technical blockers and make the content easy to cite, which improves your chances.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I block GPTBot?
&lt;/h3&gt;

&lt;p&gt;It depends on your goals. GPTBot collects training data, while OAI-SearchBot powers ChatGPT search results. OpenAI documents them separately, so you can block GPTBot and still allow OAI-SearchBot if you want to appear in ChatGPT search without contributing training data.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does llms.txt improve my AI SEO score?
&lt;/h3&gt;

&lt;p&gt;In this tool, yes: llms.txt is one of the heaviest checks, at 10 points. In the real world, Google says you do not need it for AI Overviews. It helps agents and assistants that read it, and it costs little to add.&lt;/p&gt;

&lt;h3&gt;
  
  
  How often should I run an AI SEO check?
&lt;/h3&gt;

&lt;p&gt;Run it after every deploy that touches templates, robots.txt or metadata, and on each new key page. A robots.txt change can silently block an AI crawler, so a quick re-check after infrastructure changes is worth it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;AI SEO starts with access: allow OAI-SearchBot, PerplexityBot and the other bots you want, and remember named robots.txt groups do not inherit &lt;code&gt;*&lt;/code&gt; rules.&lt;/li&gt;
&lt;li&gt;Google says no special optimization is needed for AI Overviews; normal SEO fundamentals apply.&lt;/li&gt;
&lt;li&gt;Write answer-first sections with cited sources and real numbers; the GEO paper found these gave the biggest visibility gains, and keyword stuffing did not.&lt;/li&gt;
&lt;li&gt;Add JSON-LD that matches visible text, a sitemap and an llms.txt.&lt;/li&gt;
&lt;li&gt;Audit key pages for free with the &lt;a href="https://tools.fabwaseem.com/t/ai-seo-checker" rel="noopener noreferrer"&gt;AI SEO checker&lt;/a&gt;, fix fails first, then re-run.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Run your free AI SEO check
&lt;/h2&gt;

&lt;p&gt;Paste your most important page into the &lt;strong&gt;&lt;a href="https://tools.fabwaseem.com/t/ai-seo-checker" rel="noopener noreferrer"&gt;free AI SEO checker&lt;/a&gt;&lt;/strong&gt; and fix the first failure it shows. If llms.txt is missing, make one with the &lt;a href="https://tools.fabwaseem.com/t/llms-txt-generator" rel="noopener noreferrer"&gt;llms.txt generator&lt;/a&gt;. All my free tools are on the &lt;a href="https://tools.fabwaseem.com" rel="noopener noreferrer"&gt;tools page&lt;/a&gt;, and my work is on the &lt;a href="https://www.fabwaseem.com/projects" rel="noopener noreferrer"&gt;projects page&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.fabwaseem.com/blog/ai-seo-checker" rel="noopener noreferrer"&gt;fabwaseem.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>seo</category>
      <category>ai</category>
      <category>webdev</category>
      <category>nextjs</category>
    </item>
    <item>
      <title>MongoDB vs Postgres JSONB in 2026: Inserts, Lookups, Updates and Storage, Benchmarked</title>
      <dc:creator>Waseem Anjum</dc:creator>
      <pubDate>Fri, 30 Oct 2026 04:10:09 +0000</pubDate>
      <link>https://dev.to/fabwaseem/mongodb-vs-postgres-jsonb-in-2026-inserts-lookups-updates-and-storage-benchmarked-3cf6</link>
      <guid>https://dev.to/fabwaseem/mongodb-vs-postgres-jsonb-in-2026-inserts-lookups-updates-and-storage-benchmarked-3cf6</guid>
      <description>&lt;p&gt;&lt;em&gt;Cross-posted from my blog. The original, with any updates, lives on &lt;a href="https://www.fabwaseem.com/blog/mongodb-vs-postgres-jsonb" rel="noopener noreferrer"&gt;fabwaseem.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Most "MongoDB vs Postgres JSONB" debates are fought with benchmarks from 2014 and opinions from Twitter. I run both in my day job and side projects, so I put the current versions, &lt;a href="https://www.mongodb.com/docs/manual/release-notes/9.0/" rel="noopener noreferrer"&gt;MongoDB 9.0.4&lt;/a&gt; (released October 13, 2026) and PostgreSQL 18.6, on the same VM, loaded the same 200,000 order documents into each, and measured the things a real app does all day: inserts, lookups by an indexed field, containment filters, partial updates of nested fields, aggregations and disk usage.&lt;/p&gt;

&lt;p&gt;The short version: neither one wins everywhere. Postgres answered B-tree indexed reads more than twice as fast through the Node.js drivers, but MongoDB loaded data almost 4x faster, updated nested fields about 50% faster and used &lt;strong&gt;5.6x less disk&lt;/strong&gt;. And one index choice that most JSONB tutorials recommend, a GIN index on the whole document, turned out to be the most expensive line in my Postgres schema.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR: MongoDB vs Postgres JSONB
&lt;/h2&gt;

&lt;p&gt;If your app is relational with some flexible fields, keep it in Postgres and index the JSONB paths you query with B-tree expression indexes, not one big GIN index. If your data really is documents that get written and updated a lot, and disk or cache size matters, MongoDB did that work faster and far more compactly in my tests.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;200,000 docs, 16 clients (median of 3)&lt;/th&gt;
&lt;th&gt;PostgreSQL 18.6 JSONB&lt;/th&gt;
&lt;th&gt;MongoDB 9.0.4&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Bulk insert, 1,000 per batch&lt;/td&gt;
&lt;td&gt;12,337 docs/s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;46,448 docs/s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Single inserts&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;24,594 docs/s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;18,506 docs/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Point lookup by &lt;code&gt;orderNo&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;44,701 ops/s&lt;/strong&gt; (B-tree), 29,417 (GIN)&lt;/td&gt;
&lt;td&gt;19,139 ops/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Latest 10 orders of a customer&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;19,322 ops/s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;8,855 ops/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Containment filter, &lt;code&gt;LIMIT 20&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;2,531 ops/s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4,605 ops/s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Count of 1,411 matching docs&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2,094 ops/s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1,067 ops/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nested field update&lt;/td&gt;
&lt;td&gt;16,786 ops/s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;24,603 ops/s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Append to an array&lt;/td&gt;
&lt;td&gt;16,542 ops/s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;26,755 ops/s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Revenue by country (full scan)&lt;/td&gt;
&lt;td&gt;182.8 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;154.6 ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Top 10 SKUs (unwind items)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;161.3 ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;283.1 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Disk: data + indexes after load&lt;/td&gt;
&lt;td&gt;308.4 MB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;55.2 MB&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Disk after 1.5 to 2.3 million updates&lt;/td&gt;
&lt;td&gt;1,231.5 MB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;138.8 MB&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2cn5g3t2h7lildhb2e4i.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2cn5g3t2h7lildhb2e4i.png" alt="Bar chart comparing MongoDB 9.0 and PostgreSQL 18 JSONB throughput for inserts, lookups, filters and updates" width="800" height="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How I tested
&lt;/h2&gt;

&lt;p&gt;Everything ran on one Linux VM: 8 vCPUs (Intel Xeon, KVM), 16 GB of RAM, Debian 13. The databases and the Node.js client shared the machine and talked over loopback TCP, so there's no network latency in these numbers. The VM is shared with other workloads, so I logged the load average and free memory with every result and re-ran everything after one early run stalled (more in the limitations).&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;PostgreSQL 18.6&lt;/strong&gt; (PGDG build) in its own cluster with &lt;code&gt;shared_buffers = 512MB&lt;/code&gt;, &lt;code&gt;max_wal_size = 4GB&lt;/code&gt; and otherwise default settings, including &lt;code&gt;fsync = on&lt;/code&gt; and &lt;code&gt;synchronous_commit = on&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MongoDB 9.0.4&lt;/strong&gt; Community, the official Debian 12 tarball, as a standalone server with &lt;code&gt;--wiredTigerCacheSizeGB 0.5&lt;/code&gt; so both databases got the same 512 MB of cache. Everything else default: snappy compression, journaling on, default write concern.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Node.js 22.20.0&lt;/strong&gt; with &lt;code&gt;pg&lt;/code&gt; 8.23.1 and &lt;code&gt;mongodb&lt;/code&gt; 7.7.0, the latest npm releases on October 29, 2026.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data:&lt;/strong&gt; 200,000 generated e-commerce orders from a seeded random generator, the same documents for both databases. Each order has a customer, status, total, tags, a nested &lt;code&gt;shipping&lt;/code&gt; object, 1 to 5 line items and an &lt;code&gt;events&lt;/code&gt; array. That's about 770 bytes as JSON, 979 bytes per row as JSONB and 806 bytes as BSON.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Method:&lt;/strong&gt; 16 concurrent clients (a 16-connection pool on both sides) in a closed loop for 10 seconds per test, 3 runs per test, and I report the median. Aggregations ran 5 times per run.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every query I timed returned the same answer on both databases: for example, both found exactly 1,411 orders that are shipped, going to the UAE and tagged &lt;code&gt;gift&lt;/code&gt;, and both aggregations returned identical totals.&lt;/p&gt;

&lt;h2&gt;
  
  
  The schema and the indexes
&lt;/h2&gt;

&lt;p&gt;Postgres gets one &lt;code&gt;jsonb&lt;/code&gt; column plus an identity key. To compare the two ways people index JSONB, I created both: a GIN index over the whole document and B-tree expression indexes on the fields the app looks up.&lt;br&gt;
&lt;/p&gt;

&lt;p&gt;```sql title="postgres.sql"&lt;br&gt;
CREATE TABLE orders (&lt;br&gt;
  id  bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,&lt;br&gt;
  doc jsonb NOT NULL&lt;br&gt;
);&lt;br&gt;
-- one index for any containment query (@&amp;gt;)&lt;br&gt;
CREATE INDEX orders_gin ON orders USING gin (doc jsonb_path_ops);&lt;br&gt;
-- B-tree expression indexes for the hot lookups&lt;br&gt;
CREATE UNIQUE INDEX orders_orderno ON orders ((doc-&amp;gt;&amp;gt;'orderNo'));&lt;br&gt;
CREATE INDEX orders_customer ON orders&lt;br&gt;
  (((doc-&amp;gt;&amp;gt;'customerId')::int), (doc-&amp;gt;&amp;gt;'createdAt') DESC);&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;




```js title="mongo-indexes.js"
db.orders.createIndex({ orderNo: 1 }, { unique: true });
db.orders.createIndex({ customerId: 1, createdAt: -1 });
db.orders.createIndex({ status: 1, "shipping.country": 1 });
db.orders.createIndex({ tags: 1 });
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Two things to know about the Postgres side. The query has to match the index: &lt;code&gt;doc-&amp;gt;&amp;gt;'orderNo' = $1&lt;/code&gt; uses the B-tree, &lt;code&gt;doc @&amp;gt; '{"orderNo": "..."}'&lt;/code&gt; uses the GIN index, and neither one can use the other's index. And JSONB has no date type, so &lt;code&gt;createdAt&lt;/code&gt; is an ISO 8601 string. It sorts correctly as text, but in MongoDB it's a real &lt;code&gt;Date&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  Insert throughput
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Inserts (median of 3)&lt;/th&gt;
&lt;th&gt;PostgreSQL 18.6&lt;/th&gt;
&lt;th&gt;MongoDB 9.0.4&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Bulk, 1,000 docs per call, all indexes&lt;/td&gt;
&lt;td&gt;12,337 docs/s&lt;/td&gt;
&lt;td&gt;46,448 docs/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bulk, without the GIN index&lt;/td&gt;
&lt;td&gt;26,918 docs/s&lt;/td&gt;
&lt;td&gt;not applicable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Single inserts, 16 clients, defaults&lt;/td&gt;
&lt;td&gt;24,594 docs/s (p50 0.485 ms)&lt;/td&gt;
&lt;td&gt;18,506 docs/s (p50 0.571 ms)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Single inserts, &lt;code&gt;synchronous_commit = off&lt;/code&gt; / &lt;code&gt;{ j: true }&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;31,459 docs/s&lt;/td&gt;
&lt;td&gt;16,070 docs/s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For bulk loads MongoDB was 3.8x faster: 200,000 orders in 4.3 seconds against 16.2 seconds for Postgres with a multi-row &lt;code&gt;INSERT ... SELECT unnest($1::jsonb[])&lt;/code&gt;. Half of the Postgres cost was the GIN index. Without it the same load ran at 26,918 docs/s, 2.2x faster.&lt;/p&gt;

&lt;p&gt;Single inserts flipped the result. With 16 clients each inserting one document at a time, Postgres did 24,594 per second with every commit flushed to the WAL. Postgres groups concurrent commits into one flush, which is why durability cost it so little here. MongoDB's default write concern gave 18,506 inserts per second, and asking it to wait for the journal on every insert (&lt;code&gt;{ j: true }&lt;/code&gt;) gave 16,070.&lt;/p&gt;
&lt;h2&gt;
  
  
  Point lookups: GIN vs B-tree expression index vs MongoDB index
&lt;/h2&gt;

&lt;p&gt;Fetching one order by its unique &lt;code&gt;orderNo&lt;/code&gt; is the most common query in most apps.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Lookup by &lt;code&gt;orderNo&lt;/code&gt; (median of 3)&lt;/th&gt;
&lt;th&gt;16 clients&lt;/th&gt;
&lt;th&gt;1 client, p50&lt;/th&gt;
&lt;th&gt;1 client, p99&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Postgres, B-tree expression index (&lt;code&gt;-&amp;gt;&amp;gt;&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;44,701 ops/s&lt;/td&gt;
&lt;td&gt;0.057 ms&lt;/td&gt;
&lt;td&gt;0.316 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Postgres, GIN &lt;code&gt;jsonb_path_ops&lt;/code&gt; (&lt;code&gt;@&amp;gt;&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;29,417 ops/s&lt;/td&gt;
&lt;td&gt;0.123 ms&lt;/td&gt;
&lt;td&gt;0.506 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MongoDB, &lt;code&gt;{ orderNo: 1 }&lt;/code&gt; index&lt;/td&gt;
&lt;td&gt;19,139 ops/s&lt;/td&gt;
&lt;td&gt;0.076 ms&lt;/td&gt;
&lt;td&gt;0.377 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The B-tree expression index was the fastest way to find one document in either database, and 52% faster than the GIN index on the same table. &lt;code&gt;EXPLAIN&lt;/code&gt; shows why: the B-tree query is a plain index scan, while the GIN query is a bitmap index scan plus a bitmap heap scan with a recheck, and the planner guessed 20 matching rows for a unique value because it has no real statistics for containment. With GIN you also pay for an index that covers every key and value in the document.&lt;/p&gt;

&lt;p&gt;MongoDB's own query planner used its fast &lt;code&gt;EXPRESS_IXSCAN&lt;/code&gt; path, and with one client its p50 (0.076 ms) sat between the two Postgres indexes. With 16 clients it delivered less than half the throughput of the B-tree. I didn't profile the client side, but part of the gap is likely decoding: &lt;code&gt;pg&lt;/code&gt; hands JSONB to V8's native &lt;code&gt;JSON.parse&lt;/code&gt;, while the MongoDB driver decodes BSON in JavaScript.&lt;/p&gt;
&lt;h2&gt;
  
  
  Customer feed: the latest 10 orders
&lt;/h2&gt;

&lt;p&gt;A compound index on customer and date, then &lt;code&gt;ORDER BY ... LIMIT 10&lt;/code&gt;. Both databases read the index backwards from the newest entry and stopped after 10 rows.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Latest 10 orders for a random customer&lt;/th&gt;
&lt;th&gt;Ops/s&lt;/th&gt;
&lt;th&gt;p50&lt;/th&gt;
&lt;th&gt;p99&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Postgres, expression index on &lt;code&gt;(customerId::int, createdAt DESC)&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;19,322&lt;/td&gt;
&lt;td&gt;0.67 ms&lt;/td&gt;
&lt;td&gt;2.40 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MongoDB, &lt;code&gt;{ customerId: 1, createdAt: -1 }&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;8,855&lt;/td&gt;
&lt;td&gt;1.55 ms&lt;/td&gt;
&lt;td&gt;4.50 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Postgres returned the 10 documents 2.2x faster. Same pattern as the point lookups: when the access path is a known B-tree, Postgres plus &lt;code&gt;pg&lt;/code&gt; moved more rows per second.&lt;/p&gt;
&lt;h2&gt;
  
  
  Containment filters and counts
&lt;/h2&gt;

&lt;p&gt;Now an ad-hoc filter: orders with a given status, shipping country and tag, such as shipped to the UAE with the &lt;code&gt;gift&lt;/code&gt; tag. In Postgres that's one &lt;code&gt;@&amp;gt;&lt;/code&gt; against the GIN index. In MongoDB it's a normal filter that used the &lt;code&gt;{ status, shipping.country }&lt;/code&gt; index and checked &lt;code&gt;tags&lt;/code&gt; on the fetched documents.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;doc&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;doc&lt;/span&gt; &lt;span class="o"&gt;@&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'{"status": "shipped", "shipping": {"country": "AE"}, "tags": ["gift"]}'&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;shipped&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;shipping.country&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;AE&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;gift&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Filter on 3 fields (4 rotating filters)&lt;/th&gt;
&lt;th&gt;Postgres GIN &lt;code&gt;@&amp;gt;&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;MongoDB&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;First 20 matches&lt;/td&gt;
&lt;td&gt;2,531 ops/s (p50 5.76 ms)&lt;/td&gt;
&lt;td&gt;4,605 ops/s (p50 2.86 ms)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Count all matches (1,411 for the UAE filter)&lt;/td&gt;
&lt;td&gt;2,094 ops/s (p50 6.83 ms)&lt;/td&gt;
&lt;td&gt;1,067 ops/s (p50 14.21 ms)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;MongoDB was 1.8x faster at returning the first 20, because it can walk the compound index and stop as soon as it has 20 documents. Postgres builds a bitmap from the GIN index for all matches before it reads the first row, so &lt;code&gt;LIMIT 20&lt;/code&gt; saves less. For a full count Postgres was 2x faster: the GIN index matched all three conditions at once, while MongoDB had to fetch every shipped UAE order to check its tags. A &lt;code&gt;tags&lt;/code&gt; field in MongoDB's compound index would change that, and so would a B-tree on the hot fields in Postgres. The point is that GIN gives you decent speed on any combination of fields without planning indexes, not the best speed on any one of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Partial nested updates
&lt;/h2&gt;

&lt;p&gt;Updating one nested field is where the two storage engines differ the most. Here are three updates by &lt;code&gt;orderNo&lt;/code&gt;: set &lt;code&gt;shipping.status&lt;/code&gt; and &lt;code&gt;updatedAt&lt;/code&gt;, push an event onto the &lt;code&gt;events&lt;/code&gt; array, and increment a counter.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Postgres&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;doc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jsonb_set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'{shipping,status}'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;to_jsonb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="s1"&gt;'orderNo'&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;doc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jsonb_set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'{events}'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="s1"&gt;'events'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;jsonb_build_array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;jsonb_build_object&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'type'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'at'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;())))&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="s1"&gt;'orderNo'&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// MongoDB&lt;/span&gt;
&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;updateOne&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;orderNo&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;$set&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;shipping.status&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;updatedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;updateOne&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;orderNo&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;$push&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;events&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;note&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;updateOne&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;orderNo&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;$inc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;views&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Updates by &lt;code&gt;orderNo&lt;/code&gt;, 16 clients (median of 3)&lt;/th&gt;
&lt;th&gt;Postgres, all indexes&lt;/th&gt;
&lt;th&gt;Postgres, no GIN&lt;/th&gt;
&lt;th&gt;MongoDB&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Set a nested field&lt;/td&gt;
&lt;td&gt;16,786 ops/s&lt;/td&gt;
&lt;td&gt;22,979 ops/s&lt;/td&gt;
&lt;td&gt;24,603 ops/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Append to an array&lt;/td&gt;
&lt;td&gt;16,542 ops/s&lt;/td&gt;
&lt;td&gt;22,154 ops/s&lt;/td&gt;
&lt;td&gt;26,755 ops/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Increment a counter&lt;/td&gt;
&lt;td&gt;17,604 ops/s&lt;/td&gt;
&lt;td&gt;23,298 ops/s&lt;/td&gt;
&lt;td&gt;26,638 ops/s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;MongoDB was about 50% faster than Postgres with the full set of indexes. &lt;code&gt;jsonb_set&lt;/code&gt; reads like a partial update, but Postgres stores JSONB as one value, so every update writes a complete new copy of the row. Worse, it can't be a HOT (heap-only tuple) update once any index is built on an expression over &lt;code&gt;doc&lt;/code&gt;, because Postgres only knows the indexed column changed. I checked this on a small table: 5,000 &lt;code&gt;jsonb_set&lt;/code&gt; updates gave 0 HOT updates with a &lt;code&gt;doc-&amp;gt;&amp;gt;'orderNo'&lt;/code&gt; index and 2,215 without it. So every update also inserts new entries into every index, including the GIN index, which holds an entry for every value in the document. Dropping the GIN index made Postgres updates 32 to 37% faster and closed most of the gap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Aggregations
&lt;/h2&gt;

&lt;p&gt;Two reports over all 200,000 orders: revenue and order count per shipping country for shipped and delivered orders, and the 10 best-selling SKUs, which needs unwinding the &lt;code&gt;items&lt;/code&gt; array.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="s1"&gt;'shipping'&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="s1"&gt;'country'&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;country&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="s1"&gt;'total'&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;revenue&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="s1"&gt;'status'&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'shipped'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'delivered'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="s1"&gt;'sku'&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;sku&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="s1"&gt;'qty'&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;qty&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;jsonb_array_elements&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="s1"&gt;'items'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;aggregate&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;$match&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;$in&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;shipped&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;delivered&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;$group&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;$shipping.country&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;n&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;$sum&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="na"&gt;revenue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;$sum&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;$total&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;$sort&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;revenue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;aggregate&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;$unwind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;$items&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;$group&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;$items.sku&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;qty&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;$sum&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;$items.qty&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;$sort&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;qty&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;$limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Full-collection aggregation (median of 3 x 5 runs)&lt;/th&gt;
&lt;th&gt;Postgres 18.6&lt;/th&gt;
&lt;th&gt;MongoDB 9.0.4&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Revenue by country (70% of orders match)&lt;/td&gt;
&lt;td&gt;182.8 ms&lt;/td&gt;
&lt;td&gt;154.6 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Top 10 SKUs (about 600,000 line items)&lt;/td&gt;
&lt;td&gt;161.3 ms&lt;/td&gt;
&lt;td&gt;283.1 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A split result. The grouped sum was close (MongoDB took 15% less time, which is inside the run-to-run noise for that Postgres query), and Postgres took 43% less time on the unwind. Postgres used parallel workers for these scans (2 per query by default) and still had to parse &lt;code&gt;-&amp;gt;&amp;gt;&lt;/code&gt; paths and cast text to &lt;code&gt;numeric&lt;/code&gt; for every row. &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; showed the revenue query sorting on disk with the default &lt;code&gt;work_mem&lt;/code&gt; of 4 MB, which I left untuned. If reports like this matter, move the summed fields into real columns, or raise &lt;code&gt;work_mem&lt;/code&gt; for the reporting role.&lt;/p&gt;

&lt;h2&gt;
  
  
  Storage size
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;After loading 200,000 orders&lt;/th&gt;
&lt;th&gt;PostgreSQL 18.6&lt;/th&gt;
&lt;th&gt;MongoDB 9.0.4&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Table / collection data&lt;/td&gt;
&lt;td&gt;212.6 MB&lt;/td&gt;
&lt;td&gt;45.6 MB (161.2 MB before compression)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Indexes&lt;/td&gt;
&lt;td&gt;95.7 MB (GIN 71.9 MB)&lt;/td&gt;
&lt;td&gt;9.7 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;308.4 MB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;55.2 MB&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Total without the GIN index&lt;/td&gt;
&lt;td&gt;236.5 MB&lt;/td&gt;
&lt;td&gt;not applicable&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;MongoDB used 5.6x less disk. WiredTiger compresses collections with snappy and indexes with prefix compression by default, so 161 MB of BSON became 46 MB. Postgres only compresses a JSONB value when it's bigger than about 2 KB (the TOAST threshold), and these documents averaged 979 bytes, so none were compressed. The GIN index alone was larger than MongoDB's collection and all five of its indexes together.&lt;/p&gt;

&lt;p&gt;Then I ran the update tests from the previous section and measured again. Those runs also grew the documents (new events, a &lt;code&gt;views&lt;/code&gt; counter, an &lt;code&gt;updatedAt&lt;/code&gt; field), so average document size went up by about a fifth in both databases.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;After the update tests&lt;/th&gt;
&lt;th&gt;PostgreSQL, all indexes&lt;/th&gt;
&lt;th&gt;PostgreSQL, no GIN&lt;/th&gt;
&lt;th&gt;MongoDB&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Updates applied&lt;/td&gt;
&lt;td&gt;1,461,709&lt;/td&gt;
&lt;td&gt;2,128,223&lt;/td&gt;
&lt;td&gt;2,308,992&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Total size&lt;/td&gt;
&lt;td&gt;1,231.5 MB&lt;/td&gt;
&lt;td&gt;627.0 MB&lt;/td&gt;
&lt;td&gt;138.8 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Of which indexes&lt;/td&gt;
&lt;td&gt;512.2 MB (GIN 466.0 MB)&lt;/td&gt;
&lt;td&gt;50.6 MB&lt;/td&gt;
&lt;td&gt;9.7 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Postgres grew 4x with autovacuum running at default settings, and the GIN index grew 6.5x to 466 MB even though no indexed value changed. MongoDB's indexes stayed the same size, because updating fields that aren't indexed doesn't touch its indexes. Its collection grew to 129.1 MB, of which 67.4 MB was free space WiredTiger will reuse. Postgres will reuse its dead space too after vacuum, but a heavily updated JSONB table with a whole-document GIN index needs much more disk and cache than the same data in MongoDB.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same operations in Node.js
&lt;/h2&gt;

&lt;p&gt;This is the module I used to check that each query does the same thing in both databases. A test inserted 3 orders, ran every function against both and asserted identical results. It passed.&lt;br&gt;
&lt;/p&gt;

&lt;p&gt;```ts title="orders.mjs"&lt;br&gt;
import pg from "pg";&lt;br&gt;
import { MongoClient } from "mongodb";&lt;/p&gt;

&lt;p&gt;const pool = new pg.Pool({ connectionString: process.env.PG_URL });&lt;br&gt;
const mongo = new MongoClient(process.env.MONGO_URL);&lt;br&gt;
const orders = mongo.db("shop").collection("orders");&lt;/p&gt;

&lt;p&gt;// Insert&lt;br&gt;
export const pgInsert = (doc) =&amp;gt; pool.query("INSERT INTO orders (doc) VALUES ($1)", [doc]);&lt;br&gt;
export const mgInsert = (doc) =&amp;gt; orders.insertOne(doc);&lt;/p&gt;

&lt;p&gt;// Point lookup: -&amp;gt;&amp;gt; matches the B-tree expression index; @&amp;gt; would go to GIN&lt;br&gt;
export const pgByOrderNo = async (no) =&amp;gt;&lt;br&gt;
  (await pool.query("SELECT doc FROM orders WHERE doc-&amp;gt;&amp;gt;'orderNo' = $1", [no])).rows[0]?.doc;&lt;br&gt;
export const mgByOrderNo = (no) =&amp;gt; orders.findOne({ orderNo: no });&lt;/p&gt;

&lt;p&gt;// Customer feed: latest 10 orders&lt;br&gt;
export const pgFeed = async (customerId) =&amp;gt; (await pool.query(&lt;br&gt;
  &lt;code&gt;SELECT doc FROM orders WHERE (doc-&amp;gt;&amp;gt;'customerId')::int = $1&lt;br&gt;
   ORDER BY doc-&amp;gt;&amp;gt;'createdAt' DESC LIMIT 10&lt;/code&gt;, [customerId])).rows.map((r) =&amp;gt; r.doc);&lt;br&gt;
export const mgFeed = (customerId) =&amp;gt;&lt;br&gt;
  orders.find({ customerId }).sort({ createdAt: -1 }).limit(10).toArray();&lt;/p&gt;

&lt;p&gt;// Containment filter&lt;br&gt;
export const pgFilter = async (status, country, tag) =&amp;gt; (await pool.query(&lt;br&gt;
  "SELECT doc FROM orders WHERE doc @&amp;gt; $1::jsonb LIMIT 20",&lt;br&gt;
  [JSON.stringify({ status, shipping: { country }, tags: [tag] })])).rows.map((r) =&amp;gt; r.doc);&lt;br&gt;
export const mgFilter = (status, country, tag) =&amp;gt;&lt;br&gt;
  orders.find({ status, "shipping.country": country, tags: tag }).limit(20).toArray();&lt;/p&gt;

&lt;p&gt;// Partial nested update&lt;br&gt;
export const pgShip = (no, status) =&amp;gt; pool.query(&lt;br&gt;
  &lt;code&gt;UPDATE orders SET doc = jsonb_set(doc, '{shipping,status}', to_jsonb($2::text))&lt;br&gt;
   WHERE doc-&amp;gt;&amp;gt;'orderNo' = $1&lt;/code&gt;, [no, status]);&lt;br&gt;
export const mgShip = (no, status) =&amp;gt;&lt;br&gt;
  orders.updateOne({ orderNo: no }, { $set: { "shipping.status": status } });&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;


Two driver details that cost me time. `pg` serializes a plain object to JSON for a `jsonb` parameter, but a JavaScript `Date` inside it becomes an ISO string, and it comes back as a string. And `pg` returns `numeric` and `count(*)` as strings (`"361.5"`, `"3"`), while MongoDB returns numbers, so convert before you compare or add.

## Which should you choose?

- **Your data is relational and some of it is flexible: Postgres JSONB.** Joins, foreign keys, transactions across tables and SQL reporting still matter more than raw document speed for most apps. Use B-tree expression indexes (or generated columns) for the paths you query often. They were the fastest lookups in this whole test.
- **Add a GIN index only when you need ad-hoc containment queries.** It made any combination of fields queryable, but it halved bulk insert speed, slowed updates by about a quarter and was the biggest object on disk, and it grew fastest under updates. If you only filter on a few known keys, index just those paths, for example a GIN index on `(doc-&amp;gt;'tags')` queried with `doc-&amp;gt;'tags' @&amp;gt; '["gift"]'`. It should cost far less, though I didn't measure that variant.
- **Your data is documents that get written and updated all day: MongoDB.** It loaded 3.8x faster, updated nested fields about 50% faster and stored the same orders in 18% of the space, which also means more of your working set fits in RAM.
- **Updates are frequent and documents are large: avoid whole-document rewrites in Postgres.** Put fast-changing fields like status, counters and timestamps in normal columns next to the JSONB, so updates can be HOT and don't rewrite every index.

If you're also deciding what else Postgres can absorb, I measured [Postgres full-text search against Elasticsearch and Meilisearch](https://www.fabwaseem.com/blog/postgres-full-text-search-vs-elasticsearch) and [Postgres job queues against BullMQ on Redis](https://www.fabwaseem.com/blog/postgres-job-queue-pg-boss-vs-bullmq). For primary keys on big tables, see [UUIDv7 in Postgres](https://www.fabwaseem.com/blog/uuidv7-postgres), and for the ORM layer, [Drizzle vs Prisma](https://www.fabwaseem.com/blog/drizzle-vs-prisma).

## Limitations of this benchmark

- One VM, a single node of each database, everything on loopback. No replica set, so MongoDB's write concern never waited for a second node, and Postgres had no replica either. Real networks add a round trip to every call.
- 200,000 documents of under 1 KB each, which fits in RAM. With a working set larger than memory, MongoDB's 5.6x smaller footprint would likely matter even more, but I didn't test that.
- The VM is shared with other workloads. My first full run stalled for about a minute while the VM was almost out of free memory, so I discarded it and re-ran everything. Then MongoDB crashed mid-run because a snapshot agent on the VM dropped a temporary file into its journal folder, so I restarted it with log file removal turned off (`log=(remove=false)`) and re-ran all MongoDB tests from scratch. Most tests stayed within 10% of their median across the 3 runs, but a few varied by up to 47% (one Postgres aggregation run, the GIN lookups and some update runs). That's why I report medians, and why I'd treat any gap under about 20% as noise.
- Node.js drivers on both sides, so client-side decoding (JSON vs BSON) is part of every read number.
- I didn't tune `work_mem`, autovacuum or fillfactor in Postgres, or anything in MongoDB beyond the cache size. I also didn't test transactions, sharding, schema validation or change streams.


---

*Originally published on [fabwaseem.com](https://www.fabwaseem.com/blog/mongodb-vs-postgres-jsonb).*
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

</description>
      <category>postgres</category>
      <category>mongodb</category>
      <category>node</category>
      <category>performance</category>
    </item>
    <item>
      <title>Free llms.txt Generator: What llms.txt Is and How to Create One</title>
      <dc:creator>Waseem Anjum</dc:creator>
      <pubDate>Thu, 29 Oct 2026 01:05:16 +0000</pubDate>
      <link>https://dev.to/fabwaseem/free-llmstxt-generator-what-llmstxt-is-and-how-to-create-one-nje</link>
      <guid>https://dev.to/fabwaseem/free-llmstxt-generator-what-llmstxt-is-and-how-to-create-one-nje</guid>
      <description>&lt;p&gt;&lt;em&gt;Cross-posted from my blog. The original, with any updates, lives on &lt;a href="https://www.fabwaseem.com/blog/llms-txt-generator" rel="noopener noreferrer"&gt;fabwaseem.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;An &lt;strong&gt;llms.txt generator&lt;/strong&gt; builds the &lt;code&gt;/llms.txt&lt;/code&gt; file that tells AI agents and chat assistants what your site is and which pages matter. I built a free one that reads your sitemap and writes a spec-style file in a few seconds, with no account: &lt;strong&gt;&lt;a href="https://tools.fabwaseem.com/t/llms-txt-generator" rel="noopener noreferrer"&gt;open the free llms.txt generator&lt;/a&gt;&lt;/strong&gt;. It also writes an optional &lt;code&gt;llms-full.txt&lt;/code&gt; with a short excerpt of every page.&lt;/p&gt;

&lt;p&gt;This guide explains what llms.txt is, what it does and does not do for SEO, the exact format from the official proposal, how to use the generator step by step, how to serve the file from Next.js, and how to check that it works.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is llms.txt?
&lt;/h2&gt;

&lt;p&gt;llms.txt is a Markdown file at the root of a website (&lt;code&gt;/llms.txt&lt;/code&gt;) that gives large language models a short, curated map of the site. It starts with an H1 name and a one-line summary, then lists the important pages as Markdown links with notes. Agents read it to find the right page without parsing the whole site.&lt;/p&gt;

&lt;p&gt;The format was proposed by Jeremy Howard of Answer.AI on September 3, 2024, and the &lt;a href="https://llmstxt.org/" rel="noopener noreferrer"&gt;llms.txt proposal&lt;/a&gt; was updated to v2 in August 2026. The proposal lives in an open &lt;a href="https://github.com/AnswerDotAI/llms-txt" rel="noopener noreferrer"&gt;GitHub repository&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Definition:&lt;/strong&gt; &lt;em&gt;llms.txt&lt;/em&gt; is a Markdown index for AI agents, placed at &lt;code&gt;/llms.txt&lt;/code&gt; (or at a subpath such as &lt;code&gt;/docs/llms.txt&lt;/code&gt;), that summarizes a site and links to its most useful pages. &lt;em&gt;llms-full.txt&lt;/em&gt; is a common companion file that includes the content itself, or excerpts of it, in one document.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;llms.txt is a Markdown file at &lt;code&gt;/llms.txt&lt;/code&gt; with an H1, a blockquote summary and H2 sections of links.&lt;/li&gt;
&lt;li&gt;Only the H1 is required. An &lt;code&gt;## Optional&lt;/code&gt; section marks links an agent can skip.&lt;/li&gt;
&lt;li&gt;It is a guide for agents at answer time, not an access control file like robots.txt.&lt;/li&gt;
&lt;li&gt;Google says you do not need it to appear in AI Overviews or AI Mode, so treat it as a low-cost extra for agents and AI assistants, not a ranking trick.&lt;/li&gt;
&lt;li&gt;Generate one free at &lt;a href="https://tools.fabwaseem.com/t/llms-txt-generator" rel="noopener noreferrer"&gt;tools.fabwaseem.com/t/llms-txt-generator&lt;/a&gt;, edit it, and serve it as plain text.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What is llms.txt used for?
&lt;/h2&gt;

&lt;p&gt;llms.txt is used to give AI agents a fast, clean starting point. A coding agent can read a library's llms.txt to find the right API page. A chat assistant browsing your site can read it to learn who you are and where your pricing, docs or blog live. It saves tokens because the agent skips navigation, ads and scripts.&lt;/p&gt;

&lt;p&gt;The proposal says the files are used most heavily for software documentation, and that the AI labs publish their own: OpenAI (&lt;a href="https://developers.openai.com/llms.txt" rel="noopener noreferrer"&gt;developers.openai.com/llms.txt&lt;/a&gt;), Anthropic (&lt;a href="https://docs.anthropic.com/llms.txt" rel="noopener noreferrer"&gt;docs.anthropic.com/llms.txt&lt;/a&gt;) and Google's Gemini API docs (&lt;a href="https://ai.google.dev/gemini-api/docs/llms.txt" rel="noopener noreferrer"&gt;ai.google.dev/gemini-api/docs/llms.txt&lt;/a&gt;). It also lists platforms that generate the file automatically, including Mintlify, GitBook, Yoast SEO, AIOSEO and Wix.&lt;/p&gt;

&lt;p&gt;Typical uses:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Site type&lt;/th&gt;
&lt;th&gt;What llms.txt points to&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Developer docs&lt;/td&gt;
&lt;td&gt;Quick start, API reference, SDK guides, changelog&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SaaS product&lt;/td&gt;
&lt;td&gt;Product overview, pricing, features, docs, support policy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Personal or portfolio site&lt;/td&gt;
&lt;td&gt;About, projects, CV, contact, best articles&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Blog or publication&lt;/td&gt;
&lt;td&gt;Pillar articles, topic hubs, author page&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Store&lt;/td&gt;
&lt;td&gt;Category pages, shipping and returns policy, size guides&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Does llms.txt help SEO or AI search rankings?
&lt;/h2&gt;

&lt;p&gt;llms.txt does not directly improve Google rankings. Google's guidance on &lt;a href="https://developers.google.com/search/docs/appearance/ai-features" rel="noopener noreferrer"&gt;AI features and your website&lt;/a&gt; says there are no extra requirements for AI Overviews or AI Mode and that you do not need new machine-readable files or AI text files to appear in them. Its value is for agents that choose to read it.&lt;/p&gt;

&lt;p&gt;So be honest about the trade-off. llms.txt costs a few minutes to publish and can help any assistant or coding agent that fetches it. It will not replace the basics that every AI answer engine still depends on: crawlable pages, a sitemap, clear titles, structured data that matches the page, and content that answers the question in its first lines. If you want to check those basics, run your site through the &lt;a href="https://tools.fabwaseem.com/t/ai-seo-checker" rel="noopener noreferrer"&gt;free AI SEO checker&lt;/a&gt;; it also checks for llms.txt.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is the llms.txt format?
&lt;/h2&gt;

&lt;p&gt;The format, from the official proposal, is Markdown sections in a fixed order: an optional byte-order mark, an H1 with the site or project name (the only required part), a blockquote summary, optional paragraphs or lists with no headings, then zero or more H2 sections that each contain a list of &lt;code&gt;[name](url)&lt;/code&gt; links with optional &lt;code&gt;: notes&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Here is the shape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gh"&gt;# Site name&lt;/span&gt;
&lt;span class="gt"&gt;
&amp;gt; One-sentence summary with the key facts an agent needs.&lt;/span&gt;

Optional notes about how to read the links below.

&lt;span class="gu"&gt;## Docs&lt;/span&gt;
&lt;span class="p"&gt;
-&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;Quick start&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="sx"&gt;https://example.com/docs/start.md&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;: Install and first request
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;API reference&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="sx"&gt;https://example.com/docs/api.md&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;: Every endpoint and parameter

&lt;span class="gu"&gt;## Optional&lt;/span&gt;
&lt;span class="p"&gt;
-&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;Changelog&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="sx"&gt;https://example.com/changelog&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rules worth remembering:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Element&lt;/th&gt;
&lt;th&gt;Required?&lt;/th&gt;
&lt;th&gt;Rule&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;H1 (&lt;code&gt;# Name&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;The only required section&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Blockquote (&lt;code&gt;&amp;gt; summary&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Short summary with key facts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Free text&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Paragraphs or lists, no headings&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;H2 file lists&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Markdown list of &lt;code&gt;[name](url): notes&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;## Optional&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;By convention, links an agent can skip&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Location&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;/llms.txt&lt;/code&gt; at the root or any subpath&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The proposal also suggests offering a clean Markdown version of important pages at the same URL with &lt;code&gt;.md&lt;/code&gt; added, and linking them with &lt;code&gt;rel="alternate" type="text/markdown"&lt;/code&gt;. That is optional, and many sites start with the index alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is the difference between llms.txt, llms-full.txt, robots.txt and sitemap.xml?
&lt;/h2&gt;

&lt;p&gt;robots.txt says which crawlers may fetch which paths. sitemap.xml lists every indexable URL for search engines. llms.txt is a short, curated Markdown guide for AI agents with notes about each link. llms-full.txt is a larger companion that holds page content or excerpts in one file, so an agent can read everything in a single request.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;File&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;th&gt;Audience&lt;/th&gt;
&lt;th&gt;Format&lt;/th&gt;
&lt;th&gt;Size&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;robots.txt&lt;/td&gt;
&lt;td&gt;Allow or block crawling (&lt;a href="https://www.rfc-editor.org/rfc/rfc9309.html" rel="noopener noreferrer"&gt;RFC 9309&lt;/a&gt;)&lt;/td&gt;
&lt;td&gt;All crawlers&lt;/td&gt;
&lt;td&gt;Plain text directives&lt;/td&gt;
&lt;td&gt;Tiny&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;sitemap.xml&lt;/td&gt;
&lt;td&gt;List every indexable URL (&lt;a href="https://www.sitemaps.org/protocol.html" rel="noopener noreferrer"&gt;sitemaps.org&lt;/a&gt;)&lt;/td&gt;
&lt;td&gt;Search engines&lt;/td&gt;
&lt;td&gt;XML&lt;/td&gt;
&lt;td&gt;Can be large&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;llms.txt&lt;/td&gt;
&lt;td&gt;Curated map with notes&lt;/td&gt;
&lt;td&gt;AI agents and assistants&lt;/td&gt;
&lt;td&gt;Markdown&lt;/td&gt;
&lt;td&gt;Small, fits in context&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;llms-full.txt&lt;/td&gt;
&lt;td&gt;Content or excerpts in one file&lt;/td&gt;
&lt;td&gt;AI agents&lt;/td&gt;
&lt;td&gt;Markdown&lt;/td&gt;
&lt;td&gt;Larger&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The proposal makes the same point: a sitemap often lacks LLM-readable versions, leaves out helpful external links, and is usually too big to fit in a model's context window. llms.txt is the short version.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I create an llms.txt file with the free generator?
&lt;/h2&gt;

&lt;p&gt;Enter your homepage URL in the &lt;a href="https://tools.fabwaseem.com/t/llms-txt-generator" rel="noopener noreferrer"&gt;free llms.txt generator&lt;/a&gt;. It reads &lt;code&gt;Sitemap:&lt;/code&gt; lines in your robots.txt, then your sitemap (falling back to homepage links), visits up to 30 pages, groups them into sections and writes llms.txt plus llms-full.txt. Review the preview, edit it, then copy or download it.&lt;/p&gt;

&lt;p&gt;Step by step:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Open the tool&lt;/strong&gt; at &lt;a href="https://tools.fabwaseem.com/t/llms-txt-generator" rel="noopener noreferrer"&gt;tools.fabwaseem.com/t/llms-txt-generator&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pick a mode.&lt;/strong&gt; Use &lt;em&gt;Site URL&lt;/em&gt; to crawl from your homepage, or &lt;em&gt;Paste URLs&lt;/em&gt; to list up to 30 exact pages you want included.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enter your homepage&lt;/strong&gt;, for example &lt;code&gt;https://example.com&lt;/code&gt;, and run it. The tool reads robots.txt for &lt;code&gt;Sitemap:&lt;/code&gt; entries, then the sitemap. If there is no sitemap, it uses links from your homepage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check the summary.&lt;/strong&gt; The H1 and blockquote come from your homepage title and meta description. Rewrite the summary so it states who you are and what you offer in one sentence.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Review the sections.&lt;/strong&gt; Pages are grouped by path: the homepage under &lt;em&gt;Main&lt;/em&gt;, &lt;code&gt;/blog/&lt;/code&gt; or &lt;code&gt;/articles/&lt;/code&gt; under &lt;em&gt;Blog&lt;/em&gt;, &lt;code&gt;/docs/&lt;/code&gt;-style API paths under &lt;em&gt;API&lt;/em&gt;, &lt;code&gt;/pricing&lt;/code&gt; and &lt;code&gt;/features&lt;/code&gt; under &lt;em&gt;Product&lt;/em&gt;, &lt;code&gt;/about&lt;/code&gt; and &lt;code&gt;/careers&lt;/code&gt; under &lt;em&gt;About&lt;/em&gt;, and privacy, terms and login pages under &lt;em&gt;Optional&lt;/em&gt;. Other paths get a section named after their first folder.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Edit the notes.&lt;/strong&gt; Each link carries a note from the page's description. Shorten them to what an agent needs to know.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Copy or download&lt;/strong&gt; &lt;code&gt;llms.txt&lt;/code&gt;, and &lt;code&gt;llms-full.txt&lt;/code&gt; if you want it. The full file adds a short excerpt (up to about 800 characters) of each page.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Publish&lt;/strong&gt; the file at &lt;code&gt;https://yourdomain.com/llms.txt&lt;/code&gt; and confirm it loads as plain text.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;What the tool does and does not do, so you know the limits:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Behavior&lt;/th&gt;
&lt;th&gt;Detail&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Pages read&lt;/td&gt;
&lt;td&gt;Up to 30 pages per run, up to 300 sitemap URLs considered&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sources&lt;/td&gt;
&lt;td&gt;robots.txt &lt;code&gt;Sitemap:&lt;/code&gt; lines, sitemap index (up to 4 child sitemaps), homepage links&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Safety&lt;/td&gt;
&lt;td&gt;Public http/https only; private, loopback and link-local addresses refused after DNS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Limits&lt;/td&gt;
&lt;td&gt;8-second timeout per request, size caps per file, a few runs per visitor every 10 minutes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Output&lt;/td&gt;
&lt;td&gt;Editable llms.txt and llms-full.txt, copy and download&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Account&lt;/td&gt;
&lt;td&gt;None, free&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  How do I add llms.txt to a Next.js site?
&lt;/h2&gt;

&lt;p&gt;In the Next.js App Router, either put a static &lt;code&gt;llms.txt&lt;/code&gt; file in the &lt;code&gt;public/&lt;/code&gt; folder, or create a route handler at &lt;code&gt;app/llms.txt/route.ts&lt;/code&gt; that returns the text with a &lt;code&gt;text/plain&lt;/code&gt; content type. The route handler is better when your pages come from a CMS, because the file stays in sync with your content.&lt;/p&gt;

&lt;p&gt;Static option: save the generated file as &lt;code&gt;public/llms.txt&lt;/code&gt;. Next.js serves it at &lt;code&gt;/llms.txt&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Dynamic option:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/llms.txt/route.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;getPublishedPosts&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/lib/posts&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;revalidate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3600&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;GET&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;posts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getPublishedPosts&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;lines&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;# Example Studio&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;gt; Example Studio builds Next.js apps and publishes guides on self-hosting and AI search.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;## Main&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;- [Home](https://example.com/): Services and contact&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;- [Projects](https://example.com/projects): Case studies&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;## Blog&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;posts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`- [&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;](https://example.com/blog/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;): &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;excerpt&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text/plain; charset=utf-8&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This site does exactly that: &lt;a href="https://www.fabwaseem.com/llms.txt" rel="noopener noreferrer"&gt;fabwaseem.com/llms.txt&lt;/a&gt; is built from published posts and projects. For the rest of the setup (metadata, sitemap and JSON-LD), see my guide to &lt;a href="https://www.fabwaseem.com/blog/nextjs-seo-ai-search" rel="noopener noreferrer"&gt;Next.js SEO for AI search&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I check that my llms.txt works?
&lt;/h2&gt;

&lt;p&gt;Open &lt;code&gt;https://yourdomain.com/llms.txt&lt;/code&gt; in a browser and confirm it returns HTTP 200 with a &lt;code&gt;text/plain&lt;/code&gt; or &lt;code&gt;text/markdown&lt;/code&gt; content type, starts with an H1, and has absolute https links that all load. Then give only that URL to an AI assistant and ask it questions about your site to see whether it finds the right pages.&lt;/p&gt;

&lt;p&gt;A quick checklist:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;curl -I https://yourdomain.com/llms.txt&lt;/code&gt; returns &lt;code&gt;200&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The first line is &lt;code&gt;# Your name&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The blockquote is one or two sentences with real facts, not slogans.&lt;/li&gt;
&lt;li&gt;Every link is absolute and returns 200, with no redirects to login pages.&lt;/li&gt;
&lt;li&gt;The file is small. If it is long, move secondary links to &lt;code&gt;## Optional&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;robots.txt does not block the path for the crawlers you care about. Each AI company documents its own user agents: &lt;a href="https://platform.openai.com/docs/bots" rel="noopener noreferrer"&gt;OpenAI&lt;/a&gt;, &lt;a href="https://support.anthropic.com/en/articles/8896518-does-anthropic-crawl-data-from-the-web-and-how-can-site-owners-block-the-crawler" rel="noopener noreferrer"&gt;Anthropic&lt;/a&gt; and &lt;a href="https://docs.perplexity.ai/guides/bots" rel="noopener noreferrer"&gt;Perplexity&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;a href="https://tools.fabwaseem.com/t/ai-seo-checker" rel="noopener noreferrer"&gt;AI SEO checker&lt;/a&gt; runs the H1 and link checks for you as part of a full report.&lt;/p&gt;

&lt;h2&gt;
  
  
  What should I put in llms.txt?
&lt;/h2&gt;

&lt;p&gt;Put the pages that answer the questions people actually ask about you: what you do, pricing or services, docs or guides, key articles, and how to contact you. Use a one-sentence factual summary, short notes per link, and an Optional section for legal pages and archives. Leave out thin pages, tag archives, search pages and anything behind a login.&lt;/p&gt;

&lt;p&gt;Good and bad examples:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Do&lt;/th&gt;
&lt;th&gt;Avoid&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;&amp;gt; Acme is a time-tracking app for agencies, from $9 per user per month.&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;&amp;gt; The best, most innovative solution for everyone.&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;- [Pricing](https://acme.com/pricing): Plans, limits and the free tier&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;- [Click here](https://acme.com/p?id=7)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10 to 40 curated links&lt;/td&gt;
&lt;td&gt;Every URL from the sitemap&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Absolute https URLs&lt;/td&gt;
&lt;td&gt;Relative links or staging domains&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Legal pages under &lt;code&gt;## Optional&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Legal pages at the top&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Common llms.txt mistakes
&lt;/h2&gt;

&lt;p&gt;The most common mistakes are serving the file as HTML instead of plain text, missing the H1, using relative links, dumping the entire sitemap, and writing a marketing tagline instead of a factual summary. Each one makes the file harder for an agent to parse or less useful once parsed.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Served as HTML.&lt;/strong&gt; A framework's 404 page or a rewrite returns HTML. Check the content type.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No H1.&lt;/strong&gt; The H1 is the one required element.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Headings in the intro.&lt;/strong&gt; Only H2 headings are allowed, and only for file lists.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Relative links.&lt;/strong&gt; Agents may read the file out of context. Use full URLs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stale links.&lt;/strong&gt; Regenerate after big content changes, or build it from your CMS.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expecting a ranking boost.&lt;/strong&gt; It helps agents that read it. It is not a Google ranking factor.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Is the llms.txt generator free?
&lt;/h3&gt;

&lt;p&gt;Yes. The generator at &lt;a href="https://tools.fabwaseem.com/t/llms-txt-generator" rel="noopener noreferrer"&gt;tools.fabwaseem.com/t/llms-txt-generator&lt;/a&gt; is free, needs no account and adds no watermark or branding to the file. There is a small per-visitor rate limit so the tool stays fast for everyone.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where do I put the llms.txt file?
&lt;/h3&gt;

&lt;p&gt;Put it at the root of your domain so it loads at &lt;code&gt;https://yourdomain.com/llms.txt&lt;/code&gt;. The proposal also allows subpaths, such as &lt;code&gt;/docs/llms.txt&lt;/code&gt; for documentation only, and agents should use the most specific file that covers a page.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does Google use llms.txt?
&lt;/h3&gt;

&lt;p&gt;Google's documentation for AI Overviews and AI Mode says you do not need new machine-readable files or AI text files to appear in those features. Treat llms.txt as a help for agents and assistants that read it, not as a Google ranking signal.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is llms-full.txt?
&lt;/h3&gt;

&lt;p&gt;llms-full.txt is a companion file that puts page content, or excerpts of it, into one Markdown document so an agent can read everything in one request. The generator writes one with a short excerpt per page. Keep llms.txt as the short index.&lt;/p&gt;

&lt;h3&gt;
  
  
  How often should I update llms.txt?
&lt;/h3&gt;

&lt;p&gt;Update it whenever you add or remove important pages. If your site uses a CMS, generate the file from your content with a route handler so it never goes stale. Otherwise, rerun the generator after major releases.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is llms.txt the same as robots.txt?
&lt;/h3&gt;

&lt;p&gt;No. robots.txt controls which crawlers may fetch which paths. llms.txt does not grant or block anything. It is a Markdown guide that tells agents what your site contains and which pages matter.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;llms.txt is a Markdown map for AI agents at &lt;code&gt;/llms.txt&lt;/code&gt;: an H1, a summary and H2 lists of links.&lt;/li&gt;
&lt;li&gt;It complements robots.txt and sitemap.xml. It does not replace them and is not a Google ranking factor.&lt;/li&gt;
&lt;li&gt;Keep it short and factual, with absolute links and an &lt;code&gt;## Optional&lt;/code&gt; section for secondary pages.&lt;/li&gt;
&lt;li&gt;In Next.js, serve it from &lt;code&gt;public/&lt;/code&gt; or a route handler that builds it from your content.&lt;/li&gt;
&lt;li&gt;Generate a draft in seconds with the free &lt;a href="https://tools.fabwaseem.com/t/llms-txt-generator" rel="noopener noreferrer"&gt;llms.txt generator&lt;/a&gt;, then edit it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try the llms.txt generator
&lt;/h2&gt;

&lt;p&gt;Paste your homepage into the &lt;strong&gt;&lt;a href="https://tools.fabwaseem.com/t/llms-txt-generator" rel="noopener noreferrer"&gt;free llms.txt generator&lt;/a&gt;&lt;/strong&gt;, edit the summary, and publish the file today. Then run the page through the &lt;a href="https://tools.fabwaseem.com/t/ai-seo-checker" rel="noopener noreferrer"&gt;AI SEO checker&lt;/a&gt; to see what else AI answer engines need from your site. More free tools are on the &lt;a href="https://tools.fabwaseem.com" rel="noopener noreferrer"&gt;tools page&lt;/a&gt;, and you can see what I build on my &lt;a href="https://www.fabwaseem.com/projects" rel="noopener noreferrer"&gt;projects page&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.fabwaseem.com/blog/llms-txt-generator" rel="noopener noreferrer"&gt;fabwaseem.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>seo</category>
      <category>ai</category>
      <category>webdev</category>
      <category>nextjs</category>
    </item>
    <item>
      <title>How to Self-Host Next.js with Coolify: Complete Production Guide</title>
      <dc:creator>Waseem Anjum</dc:creator>
      <pubDate>Thu, 29 Oct 2026 01:05:08 +0000</pubDate>
      <link>https://dev.to/fabwaseem/how-to-self-host-nextjs-with-coolify-complete-production-guide-hn1</link>
      <guid>https://dev.to/fabwaseem/how-to-self-host-nextjs-with-coolify-complete-production-guide-hn1</guid>
      <description>&lt;p&gt;&lt;em&gt;Cross-posted from my blog. The original, with any updates, lives on &lt;a href="https://www.fabwaseem.com/blog/self-host-nextjs-with-coolify" rel="noopener noreferrer"&gt;fabwaseem.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;To self-host Next.js with Coolify, install Coolify on a Linux VPS, connect your Git repository, pick the Nixpacks or Dockerfile build pack, set port 3000 and your environment variables, then point a domain at the server. Coolify builds the image, runs the container, and puts it behind a reverse proxy with automatic HTTPS, so every &lt;code&gt;git push&lt;/code&gt; becomes a production deploy without a managed platform in the middle.&lt;/p&gt;

&lt;p&gt;This guide walks through the whole path: server sizing, installing Coolify, choosing between Nixpacks and a hand-written Dockerfile with &lt;code&gt;output: 'standalone'&lt;/code&gt;, wiring up PostgreSQL, handling build-time vs runtime environment variables, and the production details that usually bite people later (memory limits, image optimization, caching, health checks and rollbacks). It is the same kind of setup that serves this site, &lt;a href="https://www.fabwaseem.com" rel="noopener noreferrer"&gt;fabwaseem.com&lt;/a&gt;, and projects like &lt;a href="https://www.fabwaseem.com/projects/foliora" rel="noopener noreferrer"&gt;Foliora&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why self-host Next.js with Coolify instead of a managed platform?
&lt;/h2&gt;

&lt;p&gt;Managed platforms are excellent at zero-config deploys. The trade-off is that pricing, limits and runtime behavior are decided for you. Coolify is an open-source, self-hostable alternative that gives you a similar push-to-deploy workflow on hardware you control.&lt;/p&gt;

&lt;p&gt;What you get:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Predictable cost.&lt;/strong&gt; You pay for the server, not per request, per build minute or per seat.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One box, many apps.&lt;/strong&gt; Next.js apps, APIs, workers, PostgreSQL, Redis and static sites can share a server.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Full Node.js runtime.&lt;/strong&gt; &lt;code&gt;next start&lt;/code&gt; runs as a long-lived Node process, so server actions, route handlers, ISR and middleware behave like they do locally.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No lock-in.&lt;/strong&gt; Under the hood it is Docker containers and a reverse proxy. You can leave with your Dockerfile.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What you take on: OS updates, backups, monitoring and capacity planning. If you are not willing to own those, stay on a managed host.&lt;/p&gt;

&lt;h3&gt;
  
  
  When is a managed platform still the better choice?
&lt;/h3&gt;

&lt;p&gt;If you rely heavily on a global edge network, per-region functions or a vendor's integrated analytics and image CDN, a managed platform saves real work. Self-hosting shines for full-stack apps with a database, background jobs and steady traffic, where one well-sized VPS does the job.&lt;/p&gt;

&lt;h2&gt;
  
  
  What do you need before you start?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Requirement&lt;/th&gt;
&lt;th&gt;Minimum&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Server&lt;/td&gt;
&lt;td&gt;Linux VPS with SSH access, amd64 or arm64&lt;/td&gt;
&lt;td&gt;Ubuntu LTS or Debian is the easy path&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CPU / RAM&lt;/td&gt;
&lt;td&gt;2 cores, 2 GB RAM (Coolify's documented minimum)&lt;/td&gt;
&lt;td&gt;Next.js builds are memory-hungry; give yourself headroom&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Disk&lt;/td&gt;
&lt;td&gt;10 GB free&lt;/td&gt;
&lt;td&gt;Docker images and build cache grow fast; plan for more&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Domain&lt;/td&gt;
&lt;td&gt;An A record you control&lt;/td&gt;
&lt;td&gt;Needed for automatic HTTPS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Repository&lt;/td&gt;
&lt;td&gt;GitHub, GitLab, Bitbucket or any Git URL&lt;/td&gt;
&lt;td&gt;GitHub App integration enables push-to-deploy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ports&lt;/td&gt;
&lt;td&gt;22, 80, 443 open (8000 for the first login)&lt;/td&gt;
&lt;td&gt;Close 8000 once the dashboard has a domain&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;a href="https://coolify.io/docs/get-started/installation" rel="noopener noreferrer"&gt;official Coolify installation guide&lt;/a&gt; lists the full requirements and supported distributions.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you install Coolify on a VPS?
&lt;/h2&gt;

&lt;p&gt;SSH into a fresh server as root (or a sudo user) and run the official install script:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://cdn.coollabs.io/coolify/install.sh | bash
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The script installs Docker if needed, pulls the Coolify containers and prints the dashboard URL. Then:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open &lt;code&gt;http://YOUR_SERVER_IP:8000&lt;/code&gt; and &lt;strong&gt;create the admin account immediately&lt;/strong&gt;. Whoever registers first owns the instance.&lt;/li&gt;
&lt;li&gt;Back up &lt;code&gt;/data/coolify/source/.env&lt;/code&gt;. It contains &lt;code&gt;APP_KEY&lt;/code&gt;, which encrypts values in Coolify's database; you need it to restore.&lt;/li&gt;
&lt;li&gt;In &lt;strong&gt;Settings&lt;/strong&gt;, give the dashboard its own domain (for example &lt;code&gt;coolify.example.com&lt;/code&gt;) so it is served over HTTPS, then close port 8000 in your firewall.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A basic firewall with UFW looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ufw default deny incoming
ufw default allow outgoing
ufw allow OpenSSH
ufw allow 80/tcp
ufw allow 443/tcp
ufw &lt;span class="nb"&gt;enable&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;Docker publishes container ports through its own iptables rules, which can bypass UFW. Don't publish database ports to the host unless you mean to; keep them on Coolify's internal network.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  How do you deploy a Next.js app on Coolify?
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpqxgok31fe1cd9avycep.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpqxgok31fe1cd9avycep.png" alt="Deploy flow: a git push triggers a Coolify webhook, Coolify builds the Next.js image, runs the container and routes HTTPS traffic through Traefik" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Create a project&lt;/strong&gt; in the dashboard (Projects → Add). Projects group an app with its databases and environments.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add a resource&lt;/strong&gt; → Application → choose your Git source. With the GitHub App, Coolify gets a webhook and redeploys on every push to the branch you select.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pick a build pack.&lt;/strong&gt; Start with &lt;strong&gt;Nixpacks&lt;/strong&gt; for a zero-config build, or &lt;strong&gt;Dockerfile&lt;/strong&gt; for full control (covered below).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set the exposed port&lt;/strong&gt; to &lt;code&gt;3000&lt;/code&gt;, the default for &lt;code&gt;next start&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add a domain&lt;/strong&gt; in the format &lt;code&gt;https://app.example.com&lt;/code&gt;. Using &lt;code&gt;https://&lt;/code&gt; tells Coolify to request a Let's Encrypt certificate through its proxy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add environment variables&lt;/strong&gt; (next section), then hit &lt;strong&gt;Deploy&lt;/strong&gt; and watch the build log.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Point the domain's A record at your server's IP before deploying so the certificate challenge can succeed. If you sit behind Cloudflare's proxy, use Full (strict) SSL mode so you don't create a redirect loop.&lt;/p&gt;

&lt;h3&gt;
  
  
  Nixpacks or Dockerfile: which build pack should you use?
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Nixpacks&lt;/th&gt;
&lt;th&gt;Dockerfile&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Setup&lt;/td&gt;
&lt;td&gt;None; detects Next.js from &lt;code&gt;package.json&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;You write and maintain the Dockerfile&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Image size&lt;/td&gt;
&lt;td&gt;Larger; ships the full &lt;code&gt;node_modules&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Small with &lt;code&gt;output: 'standalone'&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Control&lt;/td&gt;
&lt;td&gt;Limited; tweak with env vars or &lt;code&gt;nixpacks.toml&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Total: base image, build args, user, layers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Node version&lt;/td&gt;
&lt;td&gt;Detected from &lt;code&gt;engines&lt;/code&gt; or &lt;code&gt;.nvmrc&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Pinned in &lt;code&gt;FROM&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best for&lt;/td&gt;
&lt;td&gt;Prototypes, previews, quick side projects&lt;/td&gt;
&lt;td&gt;Production apps you'll run for a long time&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;a href="https://coolify.io/docs/applications/nextjs" rel="noopener noreferrer"&gt;Coolify Next.js guide&lt;/a&gt; documents both paths. Nixpacks is the fastest way to see your app live. For production, I prefer a Dockerfile: builds are reproducible, the image is small and the Node version doesn't change under you.&lt;/p&gt;

&lt;p&gt;If you stay on Nixpacks, pin Node explicitly so a detector update doesn't surprise you:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"engines"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"node"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;gt;=22 &amp;lt;23"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  How do you build a production Dockerfile with standalone output?
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc49rqe8f6pjbyrojimow.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc49rqe8f6pjbyrojimow.png" alt="Multi-stage Docker build for Next.js: deps stage installs packages, builder runs next build with standalone output, runner copies .next/standalone, .next/static and public and starts node server.js" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Next.js can trace exactly which files the server needs and emit a self-contained folder. Enable it in &lt;code&gt;next.config.ts&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;NextConfig&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nextConfig&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NextConfig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;output&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;standalone&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nx"&gt;nextConfig&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;next build&lt;/code&gt; now writes &lt;code&gt;.next/standalone&lt;/code&gt; with a minimal &lt;code&gt;server.js&lt;/code&gt; and only the traced dependencies. Static assets are &lt;strong&gt;not&lt;/strong&gt; copied in automatically, so the Dockerfile has to copy &lt;code&gt;.next/static&lt;/code&gt; and &lt;code&gt;public&lt;/code&gt; itself. See the &lt;a href="https://nextjs.org/docs/app/api-reference/config/next-config-js/output" rel="noopener noreferrer"&gt;&lt;code&gt;output&lt;/code&gt; option in the Next.js docs&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;A multi-stage Dockerfile based on the pattern in the official Next.js examples:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="c"&gt;# syntax=docker/dockerfile:1&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;node:22-alpine&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;base&lt;/span&gt;

&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;base&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;deps&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;apk add &lt;span class="nt"&gt;--no-cache&lt;/span&gt; libc6-compat
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; package.json package-lock.json ./&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;npm ci

&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;base&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;builder&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=deps /app/node_modules ./node_modules&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . .&lt;/span&gt;
&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; NEXT_TELEMETRY_DISABLED=1&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;npm run build

&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;base&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;runner&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; NODE_ENV=production&lt;/span&gt;
&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; NEXT_TELEMETRY_DISABLED=1&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;addgroup &lt;span class="nt"&gt;--system&lt;/span&gt; &lt;span class="nt"&gt;--gid&lt;/span&gt; 1001 nodejs &lt;span class="se"&gt;\
&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; adduser &lt;span class="nt"&gt;--system&lt;/span&gt; &lt;span class="nt"&gt;--uid&lt;/span&gt; 1001 nextjs

&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=builder /app/public ./public&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=builder --chown=nextjs:nodejs /app/.next/standalone ./&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static&lt;/span&gt;

&lt;span class="k"&gt;USER&lt;/span&gt;&lt;span class="s"&gt; nextjs&lt;/span&gt;
&lt;span class="k"&gt;EXPOSE&lt;/span&gt;&lt;span class="s"&gt; 3000&lt;/span&gt;
&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; PORT=3000&lt;/span&gt;
&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; HOSTNAME=0.0.0.0&lt;/span&gt;
&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["node", "server.js"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two lines matter more than they look:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;HOSTNAME=0.0.0.0&lt;/code&gt; makes the server listen on all interfaces. Without it, the container can listen only on its internal hostname and the proxy returns a 502 or "no available server".&lt;/li&gt;
&lt;li&gt;The non-root &lt;code&gt;nextjs&lt;/code&gt; user limits the blast radius if the app is ever compromised.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you use Prisma, add &lt;code&gt;RUN npx prisma generate&lt;/code&gt; in the builder stage before &lt;code&gt;npm run build&lt;/code&gt;, and make sure the generated client ends up in the traced output (it does by default when imported from server code).&lt;/p&gt;

&lt;p&gt;Commit the Dockerfile, switch the build pack to &lt;strong&gt;Dockerfile&lt;/strong&gt; in Coolify, keep port &lt;code&gt;3000&lt;/code&gt;, and redeploy.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you handle environment variables and secrets?
&lt;/h2&gt;

&lt;p&gt;This is the most common source of "works locally, broken in production". Next.js has two kinds of variables:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;NEXT_PUBLIC_*&lt;/code&gt; variables are inlined into the client bundle at build time.&lt;/strong&gt; Changing them in the dashboard does nothing until you rebuild.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Server-only variables&lt;/strong&gt; (&lt;code&gt;DATABASE_URL&lt;/code&gt;, API keys, auth secrets) are read at runtime by the Node process.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In Coolify each variable has a &lt;strong&gt;Build Variable&lt;/strong&gt; toggle. Turn it on for anything &lt;code&gt;next build&lt;/code&gt; needs: every &lt;code&gt;NEXT_PUBLIC_*&lt;/code&gt; value, plus anything read during static generation. With a Dockerfile build pack, build variables are passed as build args, so declare them in the builder stage:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;base&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;builder&lt;/span&gt;
&lt;span class="k"&gt;ARG&lt;/span&gt;&lt;span class="s"&gt; NEXT_PUBLIC_SITE_URL&lt;/span&gt;
&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep real secrets runtime-only. Don't bake them into image layers, because anyone with the image can read them. Validate variables at startup with a schema (for example Zod) so a missing value fails the deploy loudly instead of throwing on the first request.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you add a PostgreSQL database in Coolify?
&lt;/h2&gt;

&lt;p&gt;Inside the same project, add a resource → Database → PostgreSQL. Coolify starts it on its internal Docker network and shows two connection strings:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Internal URL&lt;/strong&gt;, using the container name as host. Use this as &lt;code&gt;DATABASE_URL&lt;/code&gt; for apps on the same server. Traffic never leaves the box.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Public URL&lt;/strong&gt;, only if you enable public access. Leave that off unless you really need external access, and prefer an SSH tunnel for ad-hoc admin work.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Run migrations as part of deploy, not by hand. With Prisma, a clean pattern is to run them before the server starts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"scripts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"build"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"prisma generate &amp;amp;&amp;amp; next build"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"start:prod"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"prisma migrate deploy &amp;amp;&amp;amp; node server.js"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you go this route, the runner image needs the Prisma CLI and the &lt;code&gt;prisma&lt;/code&gt; folder. Alternatively, use Coolify's &lt;strong&gt;pre-deployment command&lt;/strong&gt; setting to run &lt;code&gt;npx prisma migrate deploy&lt;/code&gt; in the new container before traffic switches. Then turn on &lt;strong&gt;scheduled backups&lt;/strong&gt; for the database and send them to S3-compatible storage. A backup that only lives on the same disk is not a backup.&lt;/p&gt;

&lt;h2&gt;
  
  
  What should you configure for production?
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Memory and CPU limits
&lt;/h3&gt;

&lt;p&gt;Coolify lets you set resource limits per application. Set them. A runaway Node process or a memory leak should restart one container, not take down the server and every other app on it. Builds are the hungriest step, because &lt;code&gt;next build&lt;/code&gt; on a mid-size app can spike well above the app's runtime usage, so leave headroom or use a separate build server once you run several apps.&lt;/p&gt;

&lt;h3&gt;
  
  
  Health checks and zero-downtime deploys
&lt;/h3&gt;

&lt;p&gt;Add a lightweight route handler and point Coolify's health check at it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/api/health/route.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dynamic&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;force-dynamic&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;GET&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With a passing health check, Coolify only switches traffic to the new container once it is healthy, and keeps the old one if the new one fails. Your users never see a half-started app.&lt;/p&gt;

&lt;h3&gt;
  
  
  Image optimization
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;next/image&lt;/code&gt; optimizes on the server. In a self-hosted container that means the Node process does the work and caches results on local disk. Recent Next.js versions use &lt;code&gt;sharp&lt;/code&gt; automatically, but it pays to keep an eye on CPU during traffic spikes. If images are a big part of the site, offload them to a media CDN with a custom &lt;code&gt;loader&lt;/code&gt; and let the app server focus on rendering.&lt;/p&gt;

&lt;h3&gt;
  
  
  Caching and ISR
&lt;/h3&gt;

&lt;p&gt;ISR and the data cache write to the container's filesystem by default. That works perfectly on a single container, but the cache resets on every deploy and isn't shared between replicas. If you scale out horizontally, configure a shared cache handler (Redis is the common choice). The &lt;a href="https://nextjs.org/docs/app/guides/self-hosting" rel="noopener noreferrer"&gt;Next.js self-hosting guide&lt;/a&gt; covers cache handlers, image optimization and streaming behind a proxy.&lt;/p&gt;

&lt;h3&gt;
  
  
  Rollbacks
&lt;/h3&gt;

&lt;p&gt;Every deployment is kept as an image, so rolling back is a click in the &lt;strong&gt;Deployments&lt;/strong&gt; tab. Pair that with database migrations that are backward-compatible (add columns first, remove them in a later release) so rolling back the app never breaks the schema.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you troubleshoot common Coolify + Next.js problems?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;th&gt;Likely cause&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;502 / "no available server"&lt;/td&gt;
&lt;td&gt;App listens on the wrong host or port&lt;/td&gt;
&lt;td&gt;Set &lt;code&gt;HOSTNAME=0.0.0.0&lt;/code&gt;, match "Ports Exposes" to &lt;code&gt;PORT&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Build killed, exit code 137&lt;/td&gt;
&lt;td&gt;Out of memory during &lt;code&gt;next build&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Add RAM or swap, or move builds to a build server&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;NEXT_PUBLIC_*&lt;/code&gt; value is empty&lt;/td&gt;
&lt;td&gt;Not available at build time&lt;/td&gt;
&lt;td&gt;Mark it as a Build Variable and redeploy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unstyled pages or 404 on &lt;code&gt;/_next/static&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Static folder not copied into standalone output&lt;/td&gt;
&lt;td&gt;Copy &lt;code&gt;.next/static&lt;/code&gt; and &lt;code&gt;public&lt;/code&gt; in the runner stage&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Certificate never issues&lt;/td&gt;
&lt;td&gt;DNS not pointing at the server, or port 80 blocked&lt;/td&gt;
&lt;td&gt;Check the A record, open 80/443, then redeploy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Redirect loop behind Cloudflare&lt;/td&gt;
&lt;td&gt;Flexible SSL mode&lt;/td&gt;
&lt;td&gt;Switch Cloudflare to Full (strict)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;When something fails, read the deployment log first, then the container logs in the app's &lt;strong&gt;Logs&lt;/strong&gt; tab. Nearly every problem above shows up clearly in one of the two.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is self-hosting Next.js with Coolify worth it?
&lt;/h2&gt;

&lt;p&gt;If you run more than one app, need a database next to your app and want costs you can predict, yes. Coolify gives you most of the managed-platform experience (Git deploys, previews, HTTPS, rollbacks, backups) on a server you own, and the escape hatch is a plain Dockerfile. Start with Nixpacks to get live, move to a standalone Dockerfile for production, and put limits, health checks and off-site backups in place before you put real traffic on it.&lt;/p&gt;

&lt;p&gt;Want to see what runs on this kind of stack? Browse &lt;a href="https://www.fabwaseem.com/projects" rel="noopener noreferrer"&gt;my projects&lt;/a&gt;, including &lt;a href="https://www.fabwaseem.com/projects/snagbox" rel="noopener noreferrer"&gt;Snagbox&lt;/a&gt;, or read the &lt;a href="https://docs.docker.com/build/building/multi-stage/" rel="noopener noreferrer"&gt;Docker multi-stage build docs&lt;/a&gt; to tune your images further.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Related:&lt;/strong&gt; not sure Coolify is the right panel? &lt;a href="https://www.fabwaseem.com/blog/coolify-vs-dokploy" rel="noopener noreferrer"&gt;Coolify vs Dokploy&lt;/a&gt; compares idle RAM, disk, architecture and security advisories, measured on identical fresh Ubuntu VMs.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Can you self-host Next.js with Coolify for free?
&lt;/h3&gt;

&lt;p&gt;Coolify itself is open source and free to self-host. You still pay for the server it runs on, and optionally for off-site backup storage and a domain.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does Next.js work fully when self-hosted?
&lt;/h3&gt;

&lt;p&gt;Yes. Running next start (or the standalone server.js) on Node.js supports server components, server actions, route handlers, middleware, ISR and image optimization. Features tied to a specific vendor's platform need their own replacement.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I use Nixpacks or a Dockerfile for Next.js on Coolify?
&lt;/h3&gt;

&lt;p&gt;Nixpacks is zero-config and great for getting live or for previews. A multi-stage Dockerfile with output: 'standalone' gives a smaller image, a pinned Node version and reproducible builds, which is the better fit for production.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why are my NEXT_PUBLIC environment variables empty after deploying?
&lt;/h3&gt;

&lt;p&gt;NEXT_PUBLIC_* values are inlined into the client bundle during next build. In Coolify, mark them as Build Variables (and declare them as ARG in a Dockerfile), then redeploy so the build picks them up.&lt;/p&gt;

&lt;h3&gt;
  
  
  How much RAM do I need to run Next.js on Coolify?
&lt;/h3&gt;

&lt;p&gt;Coolify's documented minimum is 2 CPU cores and 2 GB RAM for the platform itself. Next.js builds can spike memory well above runtime usage, so leave headroom, add swap, or use a separate build server as you add apps.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is Coolify a good Vercel alternative for Next.js?
&lt;/h3&gt;

&lt;p&gt;For full-stack apps with a database, workers and steady traffic, Coolify is a strong alternative with predictable costs and no lock-in. If you depend on a global edge network or vendor-integrated features, a managed platform may still save you work.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.fabwaseem.com/blog/self-host-nextjs-with-coolify" rel="noopener noreferrer"&gt;fabwaseem.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>docker</category>
      <category>devops</category>
      <category>selfhosting</category>
    </item>
    <item>
      <title>Postgres Job Queues in 2026: SKIP LOCKED vs pg-boss vs BullMQ (Redis and Postgres), Benchmarked</title>
      <dc:creator>Waseem Anjum</dc:creator>
      <pubDate>Wed, 28 Oct 2026 19:11:09 +0000</pubDate>
      <link>https://dev.to/fabwaseem/postgres-job-queues-in-2026-skip-locked-vs-pg-boss-vs-bullmq-redis-and-postgres-benchmarked-5bmf</link>
      <guid>https://dev.to/fabwaseem/postgres-job-queues-in-2026-skip-locked-vs-pg-boss-vs-bullmq-redis-and-postgres-benchmarked-5bmf</guid>
      <description>&lt;p&gt;&lt;em&gt;Cross-posted from my blog. The original, with any updates, lives on &lt;a href="https://www.fabwaseem.com/blog/postgres-job-queue-pg-boss-vs-bullmq" rel="noopener noreferrer"&gt;fabwaseem.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;Postgres job queue&lt;/strong&gt; used to mean one of two things in Node.js: a hand-written &lt;code&gt;FOR UPDATE SKIP LOCKED&lt;/code&gt; query or &lt;a href="https://github.com/timgit/pg-boss" rel="noopener noreferrer"&gt;pg-boss&lt;/a&gt;. Since BullMQ 6 shipped in July 2026, there is a third option: &lt;a href="https://bullmq.io/news/260927/bullmq-v6-postgresql/" rel="noopener noreferrer"&gt;BullMQ now runs on PostgreSQL&lt;/a&gt; as well as Redis, with the same &lt;code&gt;Queue&lt;/code&gt; and &lt;code&gt;Worker&lt;/code&gt; API. So I put all four on one box and measured them: enqueue speed, processing throughput, how fast an idle worker picks up a new job, what happens when a worker is killed mid-job, and how much space a waiting job takes.&lt;/p&gt;

&lt;p&gt;Three results surprised me. pg-boss with its default worker settings processed &lt;strong&gt;5 jobs per second&lt;/strong&gt; with 10 workers, because each worker fetches one job and then waits out a 2-second polling interval. One option fixed that, and pg-boss then matched BullMQ on Redis at about 29,000 jobs/s. The hand-written SKIP LOCKED worker got a killed worker's job back in &lt;strong&gt;0.01 seconds&lt;/strong&gt;, while every library took 60 seconds with its defaults. And once each job did 10 ms of simulated work, all four landed within about 12% of each other.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR: SKIP LOCKED vs pg-boss vs BullMQ
&lt;/h2&gt;

&lt;p&gt;If your data already lives in PostgreSQL and you process up to a few thousand jobs per second, you don't need Redis for a job queue. Use pg-boss, but set &lt;code&gt;batchSize&lt;/code&gt; and turn on LISTEN/NOTIFY, or its defaults will cap you at a few jobs per second. Write your own SKIP LOCKED queue only for simple, short jobs. Pick BullMQ on Redis when you need the highest throughput or BullMQ's features like flows, rate limiting and its dashboards, and try BullMQ's Postgres backend if you want that API without running Redis.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;50,000 no-op jobs, 10 workers (median of 3)&lt;/th&gt;
&lt;th&gt;Jobs/s&lt;/th&gt;
&lt;th&gt;Idle pickup p50&lt;/th&gt;
&lt;th&gt;Killed job back after&lt;/th&gt;
&lt;th&gt;Bytes per waiting job&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SKIP LOCKED, my 60 lines, 1 job per fetch&lt;/td&gt;
&lt;td&gt;12,588&lt;/td&gt;
&lt;td&gt;1.71 ms (LISTEN/NOTIFY)&lt;/td&gt;
&lt;td&gt;0.01 s&lt;/td&gt;
&lt;td&gt;213&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SKIP LOCKED, 50 jobs per fetch&lt;/td&gt;
&lt;td&gt;148,341&lt;/td&gt;
&lt;td&gt;not tested&lt;/td&gt;
&lt;td&gt;not tested&lt;/td&gt;
&lt;td&gt;213&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pg-boss 12.38.0, defaults&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;9,665 ms (fell behind)&lt;/td&gt;
&lt;td&gt;15+ min (default expiry, not measured)&lt;/td&gt;
&lt;td&gt;297&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pg-boss 12.38.0, &lt;code&gt;batchSize: 50&lt;/code&gt; + notify&lt;/td&gt;
&lt;td&gt;29,323&lt;/td&gt;
&lt;td&gt;3.19 ms&lt;/td&gt;
&lt;td&gt;60 s (expireInSeconds 30)&lt;/td&gt;
&lt;td&gt;297&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BullMQ 6.5.0 on Redis 8.0.2&lt;/td&gt;
&lt;td&gt;27,461&lt;/td&gt;
&lt;td&gt;1.25 ms&lt;/td&gt;
&lt;td&gt;60 s&lt;/td&gt;
&lt;td&gt;618 (RAM)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BullMQ 6.5.0 on PostgreSQL 18.6&lt;/td&gt;
&lt;td&gt;7,045&lt;/td&gt;
&lt;td&gt;2.69 ms&lt;/td&gt;
&lt;td&gt;60 s&lt;/td&gt;
&lt;td&gt;576&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;"Idle pickup" is the time from &lt;code&gt;add()&lt;/code&gt; to the handler starting while workers wait for work. "Killed job back after" is how long a job stayed stuck after I &lt;code&gt;SIGKILL&lt;/code&gt;ed the worker holding it. The pg-boss throughput row with &lt;code&gt;batchSize: 50&lt;/code&gt; ran without notify, because notify only changes how fast workers wake up for new jobs.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I tested
&lt;/h2&gt;

&lt;p&gt;Everything ran on one Linux VM: 8 vCPUs (Intel Xeon, KVM), 15 GB of RAM, Debian 13. Database, Redis and Node.js shared the machine and talked over loopback TCP, so there's no network latency in these numbers. On a real network every round trip costs more, and the per-job gaps grow.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;PostgreSQL 18.6&lt;/strong&gt; from the PGDG apt repo, with &lt;code&gt;shared_buffers = 1GB&lt;/code&gt; and &lt;code&gt;max_connections = 300&lt;/code&gt;. Everything else stayed at defaults, including &lt;code&gt;fsync = on&lt;/code&gt; and &lt;code&gt;synchronous_commit = on&lt;/code&gt;, so every commit waited for the WAL flush.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Redis 8.0.2&lt;/strong&gt;, the Debian 13 package, with Debian's default config: RDB snapshots, no AOF. That means Redis acknowledged writes before they reached disk while Postgres flushed every commit. That's how most people run them, but it's a big part of the throughput gap, so keep it in mind.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Node.js 22.20.0&lt;/strong&gt; with the latest npm releases on October 28, 2026: &lt;code&gt;pg&lt;/code&gt; 8.23.1, &lt;code&gt;pg-boss&lt;/code&gt; 12.38.0, &lt;code&gt;bullmq&lt;/code&gt; 6.5.0 and &lt;code&gt;ioredis&lt;/code&gt; 6.0.0.&lt;/li&gt;
&lt;li&gt;Each test ran in a fresh Node process against an empty queue: I dropped the table or schema (or ran &lt;code&gt;FLUSHALL&lt;/code&gt; on Redis) first. Payload: &lt;code&gt;{ userId, email, template }&lt;/code&gt;, about 70 bytes of JSON. I report the median of 3 runs unless I say otherwise.&lt;/li&gt;
&lt;li&gt;"Workers" means jobs in flight at once: BullMQ's &lt;code&gt;concurrency&lt;/code&gt;, pg-boss's &lt;code&gt;localConcurrency&lt;/code&gt; and the number of polling loops (each with its own connection) in my SKIP LOCKED worker.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The scripts and raw JSON results are in my working folder. Every number below comes from them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The contenders
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;SKIP LOCKED, hand-written.&lt;/strong&gt; One table, one index, and a worker that claims rows with &lt;code&gt;FOR UPDATE SKIP LOCKED&lt;/code&gt; inside a transaction. When the handler finishes, the transaction commits and the row is gone. If the handler throws, the transaction rolls back and the job is available again. A statement-level trigger sends &lt;code&gt;NOTIFY&lt;/code&gt; on insert so idle workers wake up at once.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;pg-boss 12.38.0.&lt;/strong&gt; The established Postgres queue for Node.js: retries with backoff, cron schedules, dead-letter queues, priorities, singleton and debounce policies, group concurrency and flows. It also uses SKIP LOCKED under the hood.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;BullMQ 6.5.0 on Redis.&lt;/strong&gt; The most popular Node.js queue, built on Redis Lua scripts. This is the setup from my &lt;a href="https://www.fabwaseem.com/blog/ai-automation-pipeline-nodejs-bullmq" rel="noopener noreferrer"&gt;AI automation pipeline with BullMQ&lt;/a&gt; post.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;BullMQ 6.5.0 on PostgreSQL.&lt;/strong&gt; New in v6. You install &lt;code&gt;pg&lt;/code&gt; and pass &lt;code&gt;createPostgresBackend&lt;/code&gt; as the last constructor argument. BullMQ creates its own &lt;code&gt;bullmq&lt;/code&gt; schema with tables and SQL functions, claims jobs with &lt;code&gt;FOR UPDATE SKIP LOCKED&lt;/code&gt; and waits with LISTEN/NOTIFY, according to its source. The BullMQ docs still call Redis "the default and the most battle-tested option".&lt;/p&gt;

&lt;h2&gt;
  
  
  Enqueue speed
&lt;/h2&gt;

&lt;p&gt;How fast each one accepts new jobs: 5,000 &lt;code&gt;add()&lt;/code&gt; calls one after another, 20,000 with 50 in flight, and 20,000 in bulk batches of 500.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Enqueue (jobs/s, median of 3)&lt;/th&gt;
&lt;th&gt;One at a time&lt;/th&gt;
&lt;th&gt;p50 per add&lt;/th&gt;
&lt;th&gt;50 in flight&lt;/th&gt;
&lt;th&gt;Bulk, 500 per call&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SKIP LOCKED (&lt;code&gt;INSERT&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;5,651&lt;/td&gt;
&lt;td&gt;0.117 ms&lt;/td&gt;
&lt;td&gt;14,611&lt;/td&gt;
&lt;td&gt;178,287&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pg-boss &lt;code&gt;send()&lt;/code&gt; / &lt;code&gt;insert()&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;1,301&lt;/td&gt;
&lt;td&gt;0.716 ms&lt;/td&gt;
&lt;td&gt;10,463&lt;/td&gt;
&lt;td&gt;49,164&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BullMQ on Redis &lt;code&gt;add()&lt;/code&gt; / &lt;code&gt;addBulk()&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;15,359&lt;/td&gt;
&lt;td&gt;0.056 ms&lt;/td&gt;
&lt;td&gt;67,909&lt;/td&gt;
&lt;td&gt;40,202&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BullMQ on Postgres &lt;code&gt;add()&lt;/code&gt; / &lt;code&gt;addBulk()&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;4,263&lt;/td&gt;
&lt;td&gt;0.172 ms&lt;/td&gt;
&lt;td&gt;12,358&lt;/td&gt;
&lt;td&gt;33,151&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Redis wins single adds by a wide margin, which is what you'd expect from an in-memory store that doesn't wait for disk. In bulk, Postgres catches up: a multi-row &lt;code&gt;INSERT&lt;/code&gt; of 500 jobs commits once, so the flush cost is shared. My plain &lt;code&gt;INSERT ... SELECT FROM jsonb_array_elements(...)&lt;/code&gt; was the fastest bulk path of all. pg-boss's single &lt;code&gt;send()&lt;/code&gt; was the slowest at 0.72 ms per call (I didn't profile why), but its bulk &lt;code&gt;insert()&lt;/code&gt; beat both BullMQ backends.&lt;/p&gt;

&lt;h2&gt;
  
  
  Processing throughput
&lt;/h2&gt;

&lt;p&gt;I loaded 50,000 jobs, started the workers and timed how long it took to drain the queue. The handler did nothing, so this measures pure queue overhead. Runs were capped at 30 seconds, and a capped run reports jobs processed divided by 30.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Drain 50,000 no-op jobs (jobs/s, median of 3)&lt;/th&gt;
&lt;th&gt;1 worker&lt;/th&gt;
&lt;th&gt;10 workers&lt;/th&gt;
&lt;th&gt;50 workers&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SKIP LOCKED, 1 job per fetch&lt;/td&gt;
&lt;td&gt;2,791&lt;/td&gt;
&lt;td&gt;12,588&lt;/td&gt;
&lt;td&gt;12,433&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SKIP LOCKED, 50 jobs per fetch&lt;/td&gt;
&lt;td&gt;63,972&lt;/td&gt;
&lt;td&gt;148,341&lt;/td&gt;
&lt;td&gt;88,266&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pg-boss, defaults (&lt;code&gt;batchSize: 1&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;0.5 (capped)&lt;/td&gt;
&lt;td&gt;5 (capped)&lt;/td&gt;
&lt;td&gt;25 (capped)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pg-boss, &lt;code&gt;batchSize: 50&lt;/code&gt;, &lt;code&gt;burstWhenBatchFull&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;5,763&lt;/td&gt;
&lt;td&gt;29,323&lt;/td&gt;
&lt;td&gt;28,389&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BullMQ on Redis&lt;/td&gt;
&lt;td&gt;10,556&lt;/td&gt;
&lt;td&gt;27,461&lt;/td&gt;
&lt;td&gt;29,969&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BullMQ on Postgres&lt;/td&gt;
&lt;td&gt;982 (capped)&lt;/td&gt;
&lt;td&gt;7,045&lt;/td&gt;
&lt;td&gt;9,185&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A few things stand out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Batching is the biggest lever on Postgres.&lt;/strong&gt; Claiming 50 rows per query cut the per-job cost so much that my 60-line worker drained 50,000 jobs in a third of a second. The catch with my version: all 50 jobs share one transaction, so one failing job rolls back the whole batch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;More workers stopped helping at 10.&lt;/strong&gt; With Postgres, Node.js and 50 connections fighting over 8 vCPUs, 50 workers were no faster than 10, and for batched SKIP LOCKED they were slower.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;BullMQ on Postgres was about 4x slower than on Redis&lt;/strong&gt; at 10 workers (7,045 vs 27,461 jobs/s). The BullMQ docs say to expect "~1.5–2× fewer jobs/s" for processing. On my box the gap was bigger, and with one worker it was about 11x. Each job state change is a durable Postgres transaction here, while Redis with default settings never waits for disk.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The pg-boss default that ran 5 jobs per second
&lt;/h2&gt;

&lt;p&gt;This is the result I'd most like every pg-boss user to see. With &lt;code&gt;work()&lt;/code&gt; and default options, 10 workers processed 150 jobs in 30 seconds.&lt;/p&gt;

&lt;p&gt;The reason is in pg-boss's worker loop. Each worker fetches &lt;code&gt;batchSize&lt;/code&gt; jobs (default 1), runs the handler, and then waits for &lt;code&gt;pollingIntervalSeconds&lt;/code&gt; (default 2) before fetching again. It only skips the wait when a NOTIFY arrives or when the fetch returned a full batch with &lt;code&gt;burstWhenBatchFull&lt;/code&gt; on, and that option is documented as ignored when &lt;code&gt;batchSize&lt;/code&gt; is 1. So &lt;code&gt;localConcurrency: 10&lt;/code&gt; with defaults means 10 jobs every 2 seconds, however long the backlog is.&lt;/p&gt;

&lt;p&gt;The fix is one object:&lt;br&gt;
&lt;/p&gt;

&lt;p&gt;```ts title="pg-boss worker that keeps up"&lt;br&gt;
import { PgBoss } from "pg-boss";&lt;/p&gt;

&lt;p&gt;const boss = new PgBoss({&lt;br&gt;
  connectionString: process.env.DATABASE_URL,&lt;br&gt;
  useListenNotify: true, // one dedicated LISTEN connection&lt;br&gt;
});&lt;br&gt;
await boss.start();&lt;br&gt;
await boss.createQueue("emails", { notify: true }); // NOTIFY on every new job&lt;/p&gt;

&lt;p&gt;await boss.work(&lt;br&gt;
  "emails",&lt;br&gt;
  {&lt;br&gt;
    localConcurrency: 10,&lt;br&gt;
    batchSize: 50,            // fetch up to 50 jobs per query&lt;br&gt;
    burstWhenBatchFull: true, // keep fetching while batches come back full&lt;br&gt;
  },&lt;br&gt;
  async (jobs) =&amp;gt; {&lt;br&gt;
    for (const job of jobs) await sendEmail(job.data);&lt;br&gt;
  },&lt;br&gt;
);&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;


With `batchSize: 50` and `burstWhenBatchFull: true`, the same 10 workers drained 50,000 jobs at 29,323 jobs/s, a bit faster than BullMQ on Redis in my runs. The handler always receives an array of jobs, now up to 50 at a time. If it throws, the whole batch fails and retries, so either keep handlers idempotent or set `perJobResults: true` and return a result for each job.

## Pickup latency: polling vs LISTEN/NOTIFY

Throughput is about backlogs. Most apps spend their time with an empty queue, where the question is how long a new job waits before a worker starts it. I started 10 idle workers, added 300 jobs at 20 per second and measured from just before `add()` to the moment the handler started.

| Idle pickup, 300 jobs at 20/s (median of 3) | p50 | p95 | p99 |
| --- | --- | --- | --- |
| SKIP LOCKED + `NOTIFY` trigger | 1.71 ms | 2.54 ms | 19.01 ms |
| pg-boss, defaults (polling every 2 s, `batchSize: 1`) | 9,665 ms | 18,446 ms | 18,704 ms |
| pg-boss, `batchSize: 50`, polling every 2 s | 972 ms | 1,905 ms | 1,972 ms |
| pg-boss, `useListenNotify` + queue `notify: true` | 3.34 ms | 6.23 ms | 14.90 ms |
| pg-boss, notify + `batchSize: 50` | 3.19 ms | 4.38 ms | 20.57 ms |
| BullMQ on Redis | 1.25 ms | 2.11 ms | 2.80 ms |
| BullMQ on Postgres | 2.69 ms | 3.54 ms | 18.70 ms |

With defaults, pg-boss couldn't even keep up with 20 jobs per second: only 130 of the 300 jobs had started 10 seconds after the last one was added, and the backlog kept growing. With `batchSize: 50` it kept up, but each job still waited for the next poll, about 1 second on average and up to 2. Turning on LISTEN/NOTIFY brought pickup down to 3 ms, close to BullMQ.

LISTEN/NOTIFY holds one dedicated connection per process, and pg-boss's docs warn it won't work through PgBouncer in transaction pooling mode. Polling keeps running as a fallback, so a broken listener costs latency, not jobs.

## Crash recovery: what happens to a job when a worker dies

I added one job, let a worker in a child process start it, killed that process with `SIGKILL` 200 ms later, started a fresh worker and timed how long the job took to come back.

| Worker killed mid-job | Job available again after |
| --- | --- |
| SKIP LOCKED (job claimed inside a transaction) | 0.01 s |
| BullMQ on Redis, defaults | 60.0 s |
| BullMQ on Postgres, defaults | 60.0 s |
| pg-boss, `expireInSeconds: 30` | 60.0 s |
| pg-boss, `heartbeatSeconds: 10` | 60.0 s (1 run) |

The SKIP LOCKED worker recovers instantly because the claim is a row lock inside an open transaction. When the process dies, its TCP connection closes, Postgres rolls the transaction back and the next worker can lock the row. Nothing has to time out.

The libraries mark the job as active and rely on a timer to notice it was abandoned. BullMQ's defaults are a 30-second `lockDuration` and a 30-second `stalledInterval`, and the job came back after 60 seconds on both backends. pg-boss expires abandoned jobs in its maintenance pass, which runs every 60 seconds by default (`monitorIntervalSeconds` and `superviseIntervalSeconds`), so `expireInSeconds: 30` and `heartbeatSeconds: 10` both came back at 60 seconds. With pg-boss's default `expireInSeconds` of 900, a crashed job would stay stuck for at least 15 minutes. I didn't wait that one out.

The instant recovery has a limit. If a worker's machine disappears instead of its process (a network partition or a VM that freezes), Postgres doesn't see the socket close until TCP keepalives give up. Set `tcp_keepalives_idle` and friends on the server, and `idle_in_transaction_session_timeout` as a backstop.

## Enqueue a job in the same transaction as your data

This is the feature Redis can't give you. A classic bug: you insert an order, commit, then crash before you enqueue the confirmation email. Or you enqueue first, the order insert fails, and the customer gets an email for an order that doesn't exist. If the job lives in the same database, it can commit or roll back with the order.

pg-boss supports this with the `db` option, which takes any object with an `executeSql` method:



```ts title="Enqueue inside your own transaction (pg-boss)"
const client = await pool.connect();
try {
  await client.query("BEGIN");
  const { rows } = await client.query(
    "INSERT INTO orders (total) VALUES ($1) RETURNING id", [total]);
  await boss.send("emails", { orderId: rows[0].id }, {
    db: { executeSql: (text, values) =&amp;gt; client.query(text, values) },
  });
  await client.query("COMMIT");
} catch (err) {
  await client.query("ROLLBACK"); // the job disappears with the order
  throw err;
} finally {
  client.release();
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;I tested it: after &lt;code&gt;ROLLBACK&lt;/code&gt;, both the order and the pg-boss job were gone. After &lt;code&gt;COMMIT&lt;/code&gt;, both were there. My SKIP LOCKED table behaved the same way, since it's just an &lt;code&gt;INSERT&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;BullMQ's Postgres backend can't do this today. Its backend interface "intentionally exposes no connection or transaction type", in the words of its source, so &lt;code&gt;queue.add()&lt;/code&gt; always runs on BullMQ's own pool. With either BullMQ backend you need an outbox table or an enqueue after commit.&lt;/p&gt;
&lt;h2&gt;
  
  
  Storage per waiting job
&lt;/h2&gt;

&lt;p&gt;I enqueued 100,000 jobs in bulk and measured the growth of the queue's tables and indexes (&lt;code&gt;pg_total_relation_size&lt;/code&gt;) or Redis &lt;code&gt;used_memory&lt;/code&gt;. One run each.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;100,000 waiting jobs&lt;/th&gt;
&lt;th&gt;Total&lt;/th&gt;
&lt;th&gt;Per job&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SKIP LOCKED table + index&lt;/td&gt;
&lt;td&gt;21.3 MB&lt;/td&gt;
&lt;td&gt;213 bytes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pg-boss&lt;/td&gt;
&lt;td&gt;29.7 MB&lt;/td&gt;
&lt;td&gt;297 bytes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BullMQ on Postgres&lt;/td&gt;
&lt;td&gt;57.6 MB&lt;/td&gt;
&lt;td&gt;576 bytes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BullMQ on Redis&lt;/td&gt;
&lt;td&gt;61.8 MB (RAM)&lt;/td&gt;
&lt;td&gt;618 bytes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;None of these matter for a normal backlog. They matter when a backlog grows to millions of jobs: on Redis it's RAM, and on Postgres it's disk. Both libraries also keep finished jobs by default: pg-boss deletes completed jobs after 7 days (&lt;code&gt;deleteAfterSeconds&lt;/code&gt;), and BullMQ keeps completed jobs until you set &lt;code&gt;removeOnComplete&lt;/code&gt;, so set a retention you can live with.&lt;/p&gt;
&lt;h2&gt;
  
  
  With real work, the queue barely matters
&lt;/h2&gt;

&lt;p&gt;No-op jobs exaggerate the differences. I reran the drain with a handler that waits 10 ms (like a fast API call) and 50 jobs in flight, which caps out at 5,000 jobs/s in theory.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;20,000 jobs, 10 ms each, 50 in flight (median of 3)&lt;/th&gt;
&lt;th&gt;Jobs/s&lt;/th&gt;
&lt;th&gt;Share of the 5,000/s ceiling&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SKIP LOCKED, 1 job per fetch&lt;/td&gt;
&lt;td&gt;4,189&lt;/td&gt;
&lt;td&gt;84%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pg-boss, &lt;code&gt;batchSize: 50&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;4,587&lt;/td&gt;
&lt;td&gt;92%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BullMQ on Redis&lt;/td&gt;
&lt;td&gt;4,469&lt;/td&gt;
&lt;td&gt;89%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BullMQ on Postgres&lt;/td&gt;
&lt;td&gt;4,037&lt;/td&gt;
&lt;td&gt;81%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Once a job does real work, every option except pg-boss's defaults spends most of its time in your handler. For the AI jobs I usually queue, where one model call takes seconds, the queue's overhead is noise. Pick on features, operations and failure behavior, not on no-op benchmarks.&lt;/p&gt;
&lt;h2&gt;
  
  
  A SKIP LOCKED queue in about 60 lines
&lt;/h2&gt;

&lt;p&gt;If your jobs are short and you only need "run this once, retry on failure", this is all it takes. It's the worker I benchmarked, trimmed for reading:&lt;br&gt;
&lt;/p&gt;

&lt;p&gt;```sql title="schema.sql"&lt;br&gt;
CREATE TABLE jobs (&lt;br&gt;
  id       bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,&lt;br&gt;
  queue    text        NOT NULL,&lt;br&gt;
  payload  jsonb       NOT NULL,&lt;br&gt;
  run_at   timestamptz NOT NULL DEFAULT now(),&lt;br&gt;
  attempts int         NOT NULL DEFAULT 0&lt;br&gt;
);&lt;br&gt;
CREATE INDEX jobs_fetch_idx ON jobs (queue, run_at, id);&lt;/p&gt;

&lt;p&gt;-- Wake idle workers on insert (one NOTIFY per statement, not per row)&lt;br&gt;
CREATE FUNCTION jobs_notify() RETURNS trigger LANGUAGE plpgsql AS $$&lt;br&gt;
  BEGIN PERFORM pg_notify('jobs', ''); RETURN NULL; END $$;&lt;br&gt;
CREATE TRIGGER jobs_notify AFTER INSERT ON jobs&lt;br&gt;
  FOR EACH STATEMENT EXECUTE FUNCTION jobs_notify();&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;




```ts title="queue.ts"
import pg from "pg";

const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 14 });

export async function enqueue(queue: string, payload: unknown, db: pg.Pool | pg.PoolClient = pool) {
  await db.query("INSERT INTO jobs (queue, payload) VALUES ($1, $2)", [queue, payload]);
}

export async function startWorkers(
  queue: string,
  concurrency: number,
  handler: (payload: any) =&amp;gt; Promise&amp;lt;void&amp;gt;,
) {
  const waiters = new Set&amp;lt;() =&amp;gt; void&amp;gt;();
  const listener = new pg.Client({ connectionString: process.env.DATABASE_URL });
  await listener.connect();
  await listener.query("LISTEN jobs");
  listener.on("notification", () =&amp;gt; { waiters.forEach((w) =&amp;gt; w()); waiters.clear(); });

  const loop = async () =&amp;gt; {
    const client = await pool.connect();
    for (;;) {
      await client.query("BEGIN");
      const { rows } = await client.query(
        `DELETE FROM jobs WHERE id IN (
           SELECT id FROM jobs
           WHERE queue = $1 AND run_at &amp;lt;= now()
           ORDER BY run_at, id
           FOR UPDATE SKIP LOCKED
           LIMIT 1)
         RETURNING id, payload`, [queue]);
      if (rows.length === 0) {
        await client.query("COMMIT");
        // sleep until NOTIFY, or 1 s as a fallback poll
        await new Promise&amp;lt;void&amp;gt;((r) =&amp;gt; { waiters.add(r); setTimeout(r, 1000); });
        continue;
      }
      try {
        await handler(rows[0].payload);
        await client.query("COMMIT");   // job done, row deleted
      } catch {
        await client.query("ROLLBACK"); // row is back in the queue
      }
    }
  };
  for (let i = 0; i &amp;lt; concurrency; i++) loop();
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Because &lt;code&gt;enqueue&lt;/code&gt; accepts a client, you get transactional enqueue for free: pass the client of your open transaction. I ran these two files as written: 200 jobs with 4 workers, 20 of which threw on their first try, were all processed exactly once with 0 rows left over, and a job enqueued inside a rolled-back transaction never ran.&lt;/p&gt;

&lt;p&gt;What it doesn't do is everything pg-boss and BullMQ spent years on: retry limits and backoff (a rollback doesn't save the &lt;code&gt;attempts&lt;/code&gt; increment), delayed retries, cron, dead-letter queues, priorities, rate limits, deduplication, metrics and a UI. It also holds a connection and an open transaction for the whole job. That's fine for jobs that take milliseconds. For jobs that take minutes it ties up your pool, and long-open transactions hold back vacuum. For long jobs, claim with a lease and commit right away:&lt;br&gt;
&lt;/p&gt;

&lt;p&gt;```sql title="Lease pattern for long jobs"&lt;br&gt;
-- add once: ALTER TABLE jobs ADD COLUMN locked_until timestamptz;&lt;br&gt;
UPDATE jobs SET locked_until = now() + interval '5 minutes', attempts = attempts + 1&lt;br&gt;
WHERE id = (&lt;br&gt;
  SELECT id FROM jobs&lt;br&gt;
  WHERE queue = $1 AND run_at &amp;lt;= now()&lt;br&gt;
    AND (locked_until IS NULL OR locked_until &amp;lt; now())&lt;br&gt;
  ORDER BY run_at, id&lt;br&gt;
  FOR UPDATE SKIP LOCKED&lt;br&gt;
  LIMIT 1)&lt;br&gt;
RETURNING id, payload, attempts;&lt;br&gt;
-- on success: DELETE FROM jobs WHERE id = $1&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;


The trade-off is the same one the libraries make: a crashed job now waits for its lease to expire instead of coming back in 10 ms.

## BullMQ on PostgreSQL: setup

Switching an existing BullMQ app to Postgres is mostly one argument:



```ts title="BullMQ 6 with the PostgreSQL backend"
import { Queue, Worker, createPostgresBackend } from "bullmq";

const opts = {
  connection: { connectionString: process.env.DATABASE_URL, max: 20, migrate: true },
};

const queue = new Queue("emails", opts, createPostgresBackend);
await queue.add("welcome", { userId: 42 });

new Worker("emails", async (job) =&amp;gt; sendEmail(job.data), { ...opts, concurrency: 10 }, createPostgresBackend);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;migrate: true&lt;/code&gt; creates or upgrades the &lt;code&gt;bullmq&lt;/code&gt; schema on first connect. You can also call &lt;code&gt;runMigrations()&lt;/code&gt; from a deploy step instead. BullMQ requires PostgreSQL 13 or newer and recommends 14 or newer, per its &lt;a href="https://docs.bullmq.io/guide/postgresql" rel="noopener noreferrer"&gt;PostgreSQL backend guide&lt;/a&gt;. Size the pool for your worker concurrency, because each worker's queries come out of it and the LISTEN connection is extra.&lt;/p&gt;

&lt;p&gt;It worked without surprises in my tests, and the identical API is a real advantage if you already know BullMQ. But it's three months old, and on my box it was the slowest at processing no-op jobs. I'd use it for new projects that want BullMQ's features on a Postgres-only stack, and keep busy existing queues on Redis for now.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which Postgres job queue should you choose?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You already run Postgres and want a full-featured queue: pg-boss.&lt;/strong&gt; Retries, cron, dead letters, priorities and transactional enqueue, with no extra infrastructure. Set &lt;code&gt;batchSize&lt;/code&gt; and &lt;code&gt;burstWhenBatchFull&lt;/code&gt; on busy queues, &lt;code&gt;useListenNotify&lt;/code&gt; plus queue &lt;code&gt;notify&lt;/code&gt; where latency matters, and a lower &lt;code&gt;expireInSeconds&lt;/code&gt; than the 15-minute default for short jobs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You need the most throughput, or BullMQ's ecosystem: BullMQ on Redis.&lt;/strong&gt; It had the fastest single enqueues, the lowest pickup latency and strong throughput without tuning. You pay with a second datastore and no transactional enqueue.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You like BullMQ but don't want Redis: BullMQ on PostgreSQL.&lt;/strong&gt; Same code, fewer moving parts, lower throughput, and no enqueue inside your own transaction yet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your jobs are short and simple: a SKIP LOCKED table.&lt;/strong&gt; Fastest in batch mode, instant crash recovery and no dependency. You own retries, scheduling and monitoring from then on.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The rough throughput limits from my no-op runs (about 7,000 to 150,000 jobs/s depending on the option) are far above what most apps need. If your queue does fewer than a few hundred jobs per second, choose on features and operations.&lt;/p&gt;

&lt;p&gt;If you're also deciding where search or IDs live, I ran similar tests for &lt;a href="https://www.fabwaseem.com/blog/postgres-full-text-search-vs-elasticsearch" rel="noopener noreferrer"&gt;Postgres full-text search vs Elasticsearch&lt;/a&gt; and &lt;a href="https://www.fabwaseem.com/blog/uuidv7-postgres" rel="noopener noreferrer"&gt;UUIDv7 primary keys in Postgres&lt;/a&gt;. For the ORM layer on top, see &lt;a href="https://www.fabwaseem.com/blog/drizzle-vs-prisma" rel="noopener noreferrer"&gt;Drizzle vs Prisma&lt;/a&gt;, and for a full worker with retries, backoff and a dead-letter queue, my &lt;a href="https://www.fabwaseem.com/blog/ai-automation-pipeline-nodejs-bullmq" rel="noopener noreferrer"&gt;BullMQ AI automation pipeline&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limitations of this benchmark
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;One VM, everything on loopback. Real networks add a round trip to every query and widen the gaps between per-job and batched approaches.&lt;/li&gt;
&lt;li&gt;Redis ran without AOF, Postgres with full durability. With &lt;code&gt;appendfsync always&lt;/code&gt;, Redis would pay a disk flush per write too. I didn't test that, or &lt;code&gt;synchronous_commit = off&lt;/code&gt; on Postgres.&lt;/li&gt;
&lt;li&gt;No-op handlers measure queue overhead only. The 10 ms test is closer to real life.&lt;/li&gt;
&lt;li&gt;Crash recovery ran 3 times per option except &lt;code&gt;heartbeatSeconds: 10&lt;/code&gt; (once). Storage ran once per option.&lt;/li&gt;
&lt;li&gt;I didn't test retries, delayed jobs, priorities, cron or flows, which is where the libraries earn their keep.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>postgres</category>
      <category>node</category>
      <category>redis</category>
      <category>performance</category>
    </item>
    <item>
      <title>Coolify vs Vercel for Next.js: Cost, Features and Trade-offs</title>
      <dc:creator>Waseem Anjum</dc:creator>
      <pubDate>Tue, 27 Oct 2026 22:03:08 +0000</pubDate>
      <link>https://dev.to/fabwaseem/coolify-vs-vercel-for-nextjs-cost-features-and-trade-offs-3lgg</link>
      <guid>https://dev.to/fabwaseem/coolify-vs-vercel-for-nextjs-cost-features-and-trade-offs-3lgg</guid>
      <description>&lt;p&gt;&lt;em&gt;Cross-posted from my blog. The original, with any updates, lives on &lt;a href="https://www.fabwaseem.com/blog/coolify-vs-vercel" rel="noopener noreferrer"&gt;fabwaseem.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Coolify vs Vercel comes down to who runs the infrastructure. Vercel is a fully managed platform that deploys Next.js with zero configuration and bills per seat plus usage, while Coolify is a free, open-source control plane that runs your app as Docker containers on servers you own, for a flat server cost.&lt;/p&gt;

&lt;p&gt;Pick Vercel when you want zero ops, global edge delivery and polished previews out of the box. Pick Coolify when you want predictable costs, databases and background workers next to your app, and almost no platform lock-in. I've run Next.js on both: this site, &lt;a href="https://www.fabwaseem.com" rel="noopener noreferrer"&gt;fabwaseem.com&lt;/a&gt;, used to live on Vercel with a Neon database and now runs on Coolify with its own PostgreSQL. This guide compares the two on the things that actually decide the choice (pricing model, Next.js feature support, previews, databases, long-running work, scaling and lock-in), using the official numbers as of October 2026, and ends with a migration checklist if you decide to switch.&lt;/p&gt;

&lt;p&gt;If you've already picked Coolify, my step-by-step guide on how to &lt;a href="https://www.fabwaseem.com/blog/self-host-nextjs-with-coolify" rel="noopener noreferrer"&gt;self-host Next.js with Coolify&lt;/a&gt; covers the full setup.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is the real difference between Coolify and Vercel?
&lt;/h2&gt;

&lt;p&gt;Vercel sells a finished platform. You connect a repository and get a managed build fleet, a serverless functions runtime, a global CDN, automatic TLS, preview URLs, analytics and spend controls. You never see a server. Vercel also maintains Next.js, so the integration is first-party.&lt;/p&gt;

&lt;p&gt;Coolify gives you the control plane and leaves the hardware to you. In its own words, Coolify "provides the deployment control plane and coordinates Docker on servers you connect." It handles Git integration, builds (Nixpacks, Railpack, a Dockerfile or Docker Compose), the reverse proxy and certificates, one-click databases and services, preview deployments, health checks and rollbacks. The server itself is yours: its capacity, OS updates, backups and recovery.&lt;/p&gt;

&lt;p&gt;Almost everything else in this comparison follows from that one difference.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxikj5ix700gv27jwumco.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxikj5ix700gv27jwumco.png" alt="Comparison table showing which responsibilities Vercel manages and which ones move to you or Coolify when you self-host" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Coolify vs Vercel: how do they compare side by side?
&lt;/h2&gt;

&lt;p&gt;Here is the short version, based on Vercel's and Coolify's own documentation and pricing pages as of October 2026.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Vercel&lt;/th&gt;
&lt;th&gt;Coolify&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Model&lt;/td&gt;
&lt;td&gt;Managed platform&lt;/td&gt;
&lt;td&gt;Docker on your servers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Free option&lt;/td&gt;
&lt;td&gt;Hobby, non-commercial&lt;/td&gt;
&lt;td&gt;Self-hosted, all features&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Paid tier&lt;/td&gt;
&lt;td&gt;Pro: $20/seat/month&lt;/td&gt;
&lt;td&gt;Cloud: from $5/month&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Billing&lt;/td&gt;
&lt;td&gt;Seats + usage&lt;/td&gt;
&lt;td&gt;Flat server cost&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Next.js&lt;/td&gt;
&lt;td&gt;First-party&lt;/td&gt;
&lt;td&gt;Full, via &lt;code&gt;next start&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Edge CDN&lt;/td&gt;
&lt;td&gt;Built in&lt;/td&gt;
&lt;td&gt;Add your own&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Previews&lt;/td&gt;
&lt;td&gt;Every branch push&lt;/td&gt;
&lt;td&gt;Per PR + wildcard DNS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Databases&lt;/td&gt;
&lt;td&gt;Marketplace add-ons&lt;/td&gt;
&lt;td&gt;One-click, on your server&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Max run time&lt;/td&gt;
&lt;td&gt;300s (800s on Pro)&lt;/td&gt;
&lt;td&gt;No platform limit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Persistent disk&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Volumes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scaling&lt;/td&gt;
&lt;td&gt;Automatic&lt;/td&gt;
&lt;td&gt;Manual&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ops and outages&lt;/td&gt;
&lt;td&gt;Vercel&lt;/td&gt;
&lt;td&gt;You&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lock-in&lt;/td&gt;
&lt;td&gt;Low to medium&lt;/td&gt;
&lt;td&gt;Very low&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The details behind each row are below.&lt;/p&gt;

&lt;h2&gt;
  
  
  How much does Vercel cost compared to Coolify?
&lt;/h2&gt;

&lt;p&gt;The two pricing models are different shapes, so compare them on your situation rather than on a single headline number.&lt;/p&gt;

&lt;h3&gt;
  
  
  What does Vercel charge?
&lt;/h3&gt;

&lt;p&gt;Vercel's &lt;strong&gt;Hobby&lt;/strong&gt; plan is free, but it's restricted to non-commercial personal use. According to Vercel's &lt;a href="https://vercel.com/docs/limits/fair-use-guidelines" rel="noopener noreferrer"&gt;fair use guidelines&lt;/a&gt;, commercial use includes any deployment built for financial gain, "including a paid employee or consultant writing the code", so client sites, SaaS products and sites with ads need Pro. Hobby also comes with fixed caps (for example 100 GB of Fast Data Transfer, 1 million function invocations and 5,000 image transformations a month), and projects are paused when you exceed them. You can't buy extra usage on Hobby.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://vercel.com/docs/plans/pro-plan" rel="noopener noreferrer"&gt;Pro plan&lt;/a&gt; costs a $20/month platform fee that includes one deploying seat and $20 of monthly usage credit. Each additional Owner or Member seat is $20/month; Viewer seats are free. Pro also includes a Flat Rate CDN tier of 1 million CDN requests and 1 TB of data transfer a month. Once the credit is used, usage is billed on demand, for example $0.60 per million function invocations, Active CPU from $0.128 an hour, and $0.05 per 1,000 image transformations. New teams get a default on-demand budget of $200 with alerts, and you can configure a hard limit that pauses projects.&lt;/p&gt;

&lt;h3&gt;
  
  
  What does Coolify charge?
&lt;/h3&gt;

&lt;p&gt;Self-hosted Coolify is free forever with no feature restrictions, according to the &lt;a href="https://coolify.io/pricing" rel="noopener noreferrer"&gt;Coolify pricing page&lt;/a&gt;. You pay your server provider, and that's it. Coolify itself needs at least 2 CPU cores, 2 GB of RAM and 10 GB of disk, and your apps need room on top of that.&lt;/p&gt;

&lt;p&gt;If you don't want to run the Coolify dashboard yourself, Coolify Cloud costs $5/month for two connected servers plus $3/month for each additional one. You still bring your own servers; Cloud only manages the Coolify instance.&lt;/p&gt;

&lt;h3&gt;
  
  
  A worked example
&lt;/h3&gt;

&lt;p&gt;Take a team of three developers who all deploy. On Vercel Pro, that's the $20 platform fee (one seat included) plus two extra seats at $20 each: &lt;strong&gt;$60/month before any usage beyond the $20 credit&lt;/strong&gt;. On Coolify, team members are unlimited, so the same three people add nothing. The bill is your server cost, whether you run one app or ten.&lt;/p&gt;

&lt;p&gt;The flip side is that a server costs the same at zero traffic as at peak, and it scales in steps (a bigger box, another box) rather than smoothly. There is also the cost that never shows up on an invoice: your time. Updates, backups and monitoring are now your job. If an hour of your time is worth more than the monthly difference, Vercel is the cheaper option.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does Next.js work the same on Coolify as on Vercel?
&lt;/h2&gt;

&lt;p&gt;For features, yes. The Next.js guide to &lt;a href="https://nextjs.org/docs/app/guides/deploying-to-platforms" rel="noopener noreferrer"&gt;deploying to platforms&lt;/a&gt; is explicit: "To run Next.js, your platform needs a Node.js server. That's it." A single &lt;code&gt;next start&lt;/code&gt; process handles Server Components, ISR, Partial Prerendering, Cache Components, Server Actions, Proxy (the new name for Middleware) and &lt;code&gt;after()&lt;/code&gt;. The only extra dependency is &lt;code&gt;sharp&lt;/code&gt; for image optimization. The same page notes that Vercel's own adapter uses the same public adapter API as every other platform.&lt;/p&gt;

&lt;p&gt;The difference is performance, not correctness. On Vercel, static assets and cached pages are served from a global edge network. On a single Coolify server, everything comes from one origin in one region. For most sites, putting a CDN such as Cloudflare in front for static assets closes most of that gap.&lt;/p&gt;

&lt;p&gt;Start from a config that is portable to both:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// next.config.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;NextConfig&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nextConfig&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NextConfig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Lean, self-contained server bundle for Docker. Harmless on Vercel.&lt;/span&gt;
  &lt;span class="na"&gt;output&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;standalone&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// Same value on every instance of one release, so clients and servers agree.&lt;/span&gt;
  &lt;span class="na"&gt;deploymentId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DEPLOYMENT_VERSION&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nx"&gt;nextConfig&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  What changes when you run more than one instance?
&lt;/h3&gt;

&lt;p&gt;This is where self-hosting gets real. By default, the ISR and data cache live on each server's local disk, so a &lt;code&gt;revalidateTag()&lt;/code&gt; call on one container doesn't reach the others. Once you scale out, you need three things the platform otherwise handles for you:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A shared cache handler (Redis is the usual choice).&lt;/li&gt;
&lt;li&gt;The same &lt;code&gt;NEXT_SERVER_ACTIONS_ENCRYPTION_KEY&lt;/code&gt; on every instance, or Server Actions fail with "Failed to find Server Action" errors.&lt;/li&gt;
&lt;li&gt;A consistent &lt;code&gt;deploymentId&lt;/code&gt; per release, so rolling deploys don't cause version skew.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// next.config.js (multi-instance setup)&lt;/span&gt;
&lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exports&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;output&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;standalone&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;cacheHandler&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;require&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./cache-handler.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="c1"&gt;// Redis-backed handler&lt;/span&gt;
  &lt;span class="na"&gt;cacheMaxMemorySize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// disable the per-process in-memory cache&lt;/span&gt;
  &lt;span class="na"&gt;deploymentId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DEPLOYMENT_VERSION&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On a single Coolify server running one container, you need none of this. That's one reason a single, well-sized server is often the sweet spot.&lt;/p&gt;

&lt;h3&gt;
  
  
  What about image optimization?
&lt;/h3&gt;

&lt;p&gt;On Vercel, &lt;code&gt;next/image&lt;/code&gt; transformations are a metered resource. On Coolify, they're free in dollars but cost CPU and memory on your server. If your site is image-heavy, a custom loader that points to an image CDN keeps both bills predictable, and it works the same on either platform:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// lib/cloudinary-loader.ts&lt;/span&gt;
&lt;span class="c1"&gt;// next.config.ts: images: { loader: 'custom', loaderFile: './lib/cloudinary-loader.ts' }&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;cloudinaryLoader&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;quality&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;src&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;
  &lt;span class="nx"&gt;quality&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;f_auto&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;c_limit&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`w_&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`q_&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;quality&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;auto&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`https://res.cloudinary.com/&amp;lt;your-cloud&amp;gt;/image/upload/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  When is Vercel the better choice?
&lt;/h2&gt;

&lt;p&gt;Vercel wins clearly in these situations:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Nobody on the team wants to own a server.&lt;/strong&gt; For a commercial project that has to be live today with no ops time available, Pro is worth the money.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your audience is global and latency-sensitive.&lt;/strong&gt; A built-in edge network is hard to match with one server in one region.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Previews are central to how you review work.&lt;/strong&gt; Every branch push gets a preview URL with no DNS or webhook setup.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Traffic is spiky or unpredictable.&lt;/strong&gt; Functions scale automatically, and you pay for usage instead of provisioning for the peak.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's a personal, non-commercial project.&lt;/strong&gt; Hobby is free and perfectly good for that.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You already depend on Vercel-only products.&lt;/strong&gt; If you use Edge Config, Vercel Blob or Vercel's analytics heavily, staying avoids a rewrite.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  When is Coolify the better choice?
&lt;/h2&gt;

&lt;p&gt;Coolify wins when the app is more than a frontend:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Several apps and databases on one bill.&lt;/strong&gt; A single server can run a Next.js frontend, a NestJS API, PostgreSQL, Redis and a worker, all on the same private Docker network. An app like &lt;a href="https://www.fabwaseem.com/projects/foliora" rel="noopener noreferrer"&gt;Foliora&lt;/a&gt;, a Next.js portfolio and blog platform on Prisma and PostgreSQL, maps naturally onto one Coolify project with the database right next to it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Long-running work.&lt;/strong&gt; Vercel Functions stop at their maximum duration (300 seconds by default, up to 800 seconds on Pro). Queues, scheduled jobs and media processing don't fit that model well. A self-hostable media download API like &lt;a href="https://www.fabwaseem.com/projects/snagbox" rel="noopener noreferrer"&gt;Snagbox&lt;/a&gt; is exactly the kind of workload that belongs in a long-running container.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Persistent files.&lt;/strong&gt; Uploads, generated files and SQLite databases can live on a volume instead of a separate storage service.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Predictable cost.&lt;/strong&gt; No per-seat pricing and no usage meter. The bill only changes when you change the server.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Control over where data lives.&lt;/strong&gt; You choose the provider and the region.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Minimal lock-in.&lt;/strong&gt; Everything is a Docker image, so moving to another server or provider is a redeploy, not a migration project.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What do you give up when you self-host with Coolify?
&lt;/h2&gt;

&lt;p&gt;Be honest with yourself about this list before you switch, because it's the real price of the lower bill:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You're the ops team.&lt;/strong&gt; OS security patches, Docker updates, Coolify updates and disk space (old images pile up) are yours to watch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backups are your responsibility.&lt;/strong&gt; Coolify can schedule dumps for PostgreSQL, MySQL, MariaDB and MongoDB, but its docs are clear that a rollback "does not restore databases or persistent files". Send backups off the server.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One server is one point of failure.&lt;/strong&gt; If it goes down, everything on it goes down until you fix it or fail over.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Security starts at install.&lt;/strong&gt; Coolify's install guide warns that "anyone who reaches the registration page first can become the instance admin and gain root access to your server", so create the admin account immediately.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Previews run untrusted code.&lt;/strong&gt; A pull request can execute code on your server, so keep preview access to repository members and never give previews production credentials.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Add a health check so Coolify's rolling updates only switch traffic to a container that actually works:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/api/health/route.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;connection&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next/server&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;GET&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="c1"&gt;// always run at request time, never prerendered&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ok&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;time&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Point the health check at &lt;code&gt;/api/health&lt;/code&gt; in the app's settings. If your app can't serve requests without the database, check the connection there too.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you migrate a Next.js app from Vercel to Coolify?
&lt;/h2&gt;

&lt;p&gt;Coolify's documentation recommends deploying first and moving DNS last. This is the order I'd follow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Audit what's Vercel-specific.&lt;/strong&gt; Search for platform SDKs and config that won't move with you:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"@vercel/"&lt;/span&gt; package.json app src lib 2&amp;gt;/dev/null
   &lt;span class="nb"&gt;cat &lt;/span&gt;vercel.json 2&amp;gt;/dev/null   &lt;span class="c"&gt;# rewrites, headers and crons live here&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rewrites and headers can move into &lt;code&gt;next.config.ts&lt;/code&gt;. Vercel cron jobs become Coolify scheduled tasks or a small worker container.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Make the build portable.&lt;/strong&gt; Add &lt;code&gt;output: 'standalone'&lt;/code&gt; and either let Nixpacks detect the app or use a multi-stage Dockerfile (the &lt;a href="https://www.fabwaseem.com/blog/self-host-nextjs-with-coolify" rel="noopener noreferrer"&gt;self-hosting guide&lt;/a&gt; has one you can copy).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deploy to Coolify without touching DNS.&lt;/strong&gt; Set environment variables and mark every &lt;code&gt;NEXT_PUBLIC_*&lt;/code&gt; value as a build-time variable, because those are inlined during &lt;code&gt;next build&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Move the data with writes stopped.&lt;/strong&gt; For PostgreSQL, a dump and restore is the simplest safe path:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   pg_dump &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$OLD_DATABASE_URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;custom &lt;span class="nt"&gt;--no-owner&lt;/span&gt; &lt;span class="nt"&gt;--no-acl&lt;/span&gt; &lt;span class="nt"&gt;--file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;app.dump
   pg_restore &lt;span class="nt"&gt;--dbname&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$NEW_DATABASE_URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--no-owner&lt;/span&gt; &lt;span class="nt"&gt;--no-acl&lt;/span&gt; app.dump
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Test on the generated Coolify domain.&lt;/strong&gt; Check the health endpoint, logins, forms, image optimization and revalidation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Switch DNS.&lt;/strong&gt; Lower the TTL a day ahead, then point the A record at your server and let Coolify issue the certificate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep Vercel running until you're sure.&lt;/strong&gt; Watch the runtime logs for a few days, then remove the old project.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  So, should you use Coolify or Vercel for Next.js?
&lt;/h2&gt;

&lt;p&gt;Use Vercel if your time is the scarcest resource, your traffic is global or spiky, and the app is mostly a frontend. Use Coolify if you run several apps or services, need databases, workers or persistent files next to your app, and want a bill that doesn't move with traffic or team size. Plenty of teams do both: marketing sites on Vercel, APIs and data on a Coolify server.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flhenfav1hlsnw97juizc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flhenfav1hlsnw97juizc.png" alt="Decision flowchart for choosing between Coolify and Vercel based on ops time, long-running work, budget and global latency" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For full-stack work, a single, well-sized Coolify server with off-site backups and a CDN in front is a strong default. Vercel stays the easy answer when a project is frontend-only or there's simply no one to look after a server. If you're weighing this for a product of your own, take a look at &lt;a href="https://www.fabwaseem.com" rel="noopener noreferrer"&gt;what I build&lt;/a&gt; and the projects I've built.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Related:&lt;/strong&gt; comparing self-hosted panels instead? In &lt;a href="https://www.fabwaseem.com/blog/coolify-vs-dokploy" rel="noopener noreferrer"&gt;Coolify vs Dokploy&lt;/a&gt; I installed both on identical VMs: Coolify idled at about 460 to 520 MiB of RAM and 3.0 GB of disk, Dokploy at about 831 MiB and 5.0 GB.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Is Coolify a good alternative to Vercel for Next.js?
&lt;/h3&gt;

&lt;p&gt;Yes, for most full-stack apps. Next.js only needs a Node.js server, so features like Server Components, ISR, Server Actions and Proxy work on Coolify through next start or a standalone Docker image. What you give up is Vercel's managed global edge network and zero-ops experience: you look after the server, updates and backups yourself.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is Coolify really free?
&lt;/h3&gt;

&lt;p&gt;Self-hosted Coolify is free forever with every feature included; you only pay for the servers you run it on. If you'd rather not host the Coolify dashboard yourself, Coolify Cloud costs $5 a month for two connected servers plus $3 a month for each additional server, and you still bring your own servers.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I use Vercel's free Hobby plan for a client or business website?
&lt;/h3&gt;

&lt;p&gt;No. Vercel's fair use guidelines restrict Hobby to non-commercial personal use. Commercial use includes sites that take payments, advertise a product or service, show ads, or are built by a paid employee or consultant. Those need the Pro or Enterprise plan.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does ISR work when you self-host Next.js on Coolify?
&lt;/h3&gt;

&lt;p&gt;Yes. On a single next start instance, ISR and on-demand revalidation work out of the box, with the cache stored on the container's disk. Regenerated pages don't survive a redeploy unless you persist the cache, and if you run several instances you need a shared cache handler, usually Redis, so revalidateTag reaches every instance.&lt;/p&gt;

&lt;h3&gt;
  
  
  How long can a request run on Vercel compared to Coolify?
&lt;/h3&gt;

&lt;p&gt;Vercel Functions run for 300 seconds by default. Hobby is capped at 300 seconds, Pro can go up to 800 seconds, and a 30-minute extended maximum is in beta for some runtimes. Coolify runs normal containers, so there is no platform time limit, although long jobs are still better handled by a background worker than by a web request.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I move from Vercel to Coolify without downtime?
&lt;/h3&gt;

&lt;p&gt;Yes, if you deploy first and move DNS last. Deploy to Coolify, set environment variables, test on the generated domain, lower your DNS TTL ahead of time, then switch the A record and keep the Vercel project running until the new deployment is stable. The only planned pause is a short write freeze while you dump and restore the database.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.fabwaseem.com/blog/coolify-vs-vercel" rel="noopener noreferrer"&gt;fabwaseem.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>vercel</category>
      <category>selfhosted</category>
      <category>devops</category>
    </item>
    <item>
      <title>Next.js SEO for AI Search: How to Get Cited by ChatGPT, Perplexity and AI Overviews</title>
      <dc:creator>Waseem Anjum</dc:creator>
      <pubDate>Tue, 27 Oct 2026 22:03:07 +0000</pubDate>
      <link>https://dev.to/fabwaseem/nextjs-seo-for-ai-search-how-to-get-cited-by-chatgpt-perplexity-and-ai-overviews-4md</link>
      <guid>https://dev.to/fabwaseem/nextjs-seo-for-ai-search-how-to-get-cited-by-chatgpt-perplexity-and-ai-overviews-4md</guid>
      <description>&lt;p&gt;&lt;em&gt;Cross-posted from my blog. The original, with any updates, lives on &lt;a href="https://www.fabwaseem.com/blog/nextjs-seo-ai-search" rel="noopener noreferrer"&gt;fabwaseem.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Next.js SEO in 2026 means two audiences at once: classic search crawlers like Googlebot, and AI answer engines like ChatGPT search, Perplexity and Google AI Overviews. The good news is that both reward the same foundations. The App Router gives you a typed Metadata API, file-based &lt;code&gt;sitemap.ts&lt;/code&gt; and &lt;code&gt;robots.ts&lt;/code&gt;, and server-rendered HTML, so you can ship a site that is easy to crawl, easy to understand and easy to quote.&lt;/p&gt;

&lt;p&gt;This guide walks through exactly what this site, &lt;a href="https://www.fabwaseem.com" rel="noopener noreferrer"&gt;fabwaseem.com&lt;/a&gt;, implements: per-page metadata with canonical URLs, a dynamic sitemap, a robots.txt that names each AI crawler, JSON-LD for posts and projects, an &lt;code&gt;llms.txt&lt;/code&gt; file, and a content structure built around question headings and direct answers. Every code sample targets the current Next.js 16 App Router.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is AI search optimization (GEO and AEO)?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Generative engine optimization (GEO)&lt;/strong&gt;, also called answer engine optimization (AEO), is the practice of making your pages easy for AI systems to find, understand and cite when they generate an answer. In practice it is technical SEO plus content written in self-contained, quotable chunks: clear definitions, direct answers, tables and sourced facts.&lt;/p&gt;

&lt;p&gt;The terms are newer than the work. Google states plainly that "there are no additional requirements to appear in AI Overviews or AI Mode, nor other special optimizations necessary" (&lt;a href="https://developers.google.com/search/docs/appearance/ai-features" rel="noopener noreferrer"&gt;Google Search Central&lt;/a&gt;). So GEO is not a separate trick. It is solid SEO, plus a writing style that survives being chopped into a snippet.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Term&lt;/th&gt;
&lt;th&gt;Optimizes for&lt;/th&gt;
&lt;th&gt;Main output&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SEO&lt;/td&gt;
&lt;td&gt;Ranking in classic search results&lt;/td&gt;
&lt;td&gt;Blue links, rich results&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AEO&lt;/td&gt;
&lt;td&gt;Being the direct answer to a question&lt;/td&gt;
&lt;td&gt;Featured snippets, voice and AI answers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GEO&lt;/td&gt;
&lt;td&gt;Being cited inside generated answers&lt;/td&gt;
&lt;td&gt;Citations in ChatGPT, Perplexity, AI Overviews&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  How do AI answer engines find and cite pages?
&lt;/h2&gt;

&lt;p&gt;AI answer engines cite pages they can crawl, index and retrieve for a query. ChatGPT search uses OpenAI's &lt;code&gt;OAI-SearchBot&lt;/code&gt;, Perplexity uses &lt;code&gt;PerplexityBot&lt;/code&gt;, and Google AI Overviews draw on Google's normal index. If a crawler is blocked, the page cannot render content server-side, or the answer is buried, you are unlikely to be cited.&lt;/p&gt;

&lt;p&gt;A few facts from the official documentation are worth knowing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;To be shown as a supporting link in AI Overviews or AI Mode, "a page must be indexed and eligible to be shown in Google Search with a snippet" (&lt;a href="https://developers.google.com/search/docs/appearance/ai-features" rel="noopener noreferrer"&gt;Google Search Central&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;Google AI Overviews and AI Mode "may use a 'query fan-out' technique", issuing multiple related searches across subtopics. That rewards pages that answer many related sub-questions clearly.&lt;/li&gt;
&lt;li&gt;OpenAI documents separate crawlers for search (&lt;code&gt;OAI-SearchBot&lt;/code&gt;), user-triggered fetches (&lt;code&gt;ChatGPT-User&lt;/code&gt;) and model training (&lt;code&gt;GPTBot&lt;/code&gt;) (&lt;a href="https://platform.openai.com/docs/bots" rel="noopener noreferrer"&gt;OpenAI bots&lt;/a&gt;). Perplexity documents &lt;code&gt;PerplexityBot&lt;/code&gt; and &lt;code&gt;Perplexity-User&lt;/code&gt; (&lt;a href="https://docs.perplexity.ai/guides/bots" rel="noopener noreferrer"&gt;Perplexity bots&lt;/a&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7d639gufxctxezdqo3g9.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7d639gufxctxezdqo3g9.jpg" alt="Diagram of how a Next.js App Router page reaches Google, ChatGPT and Perplexity: metadata, sitemap, robots, JSON-LD and llms.txt feed crawlers, which feed indexes and AI answers" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The pipeline is simple: crawlers discover URLs (sitemap and internal links), check permission (robots.txt), fetch HTML (server rendering), understand it (metadata, headings, JSON-LD), and then retrieval systems pick the passage that best answers the question. Each step below maps to one piece of that chain.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you set metadata in the Next.js App Router?
&lt;/h2&gt;

&lt;p&gt;Use the Metadata API: export a static &lt;code&gt;metadata&lt;/code&gt; object or an async &lt;code&gt;generateMetadata&lt;/code&gt; function from any &lt;code&gt;layout.tsx&lt;/code&gt; or &lt;code&gt;page.tsx&lt;/code&gt;. Next.js merges it down the route tree and renders the &lt;code&gt;&amp;lt;title&amp;gt;&lt;/code&gt;, description, canonical, Open Graph and Twitter tags into server HTML. Set &lt;code&gt;metadataBase&lt;/code&gt; once in the root layout so relative URLs resolve to absolute ones (&lt;a href="https://nextjs.org/docs/app/api-reference/functions/generate-metadata" rel="noopener noreferrer"&gt;Next.js docs&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Start in the root layout with site-wide defaults and a title template:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/layout.tsx&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Metadata&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Metadata&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;metadataBase&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://www.fabwaseem.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Waseem Anjum: Senior Full Stack Developer&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;%s | Waseem Anjum&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Senior full stack developer building with Next.js, React, Node.js, NestJS and TypeScript.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;alternates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;canonical&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;types&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/rss+xml&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/blog/rss.xml&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;openGraph&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;website&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;siteName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Waseem Anjum&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en_US&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;twitter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;card&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;summary_large_image&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;robots&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;index&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;follow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;RootLayout&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;children&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;children&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;React&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ReactNode&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;html&lt;/span&gt; &lt;span class="na"&gt;lang&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"en"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;body&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;body&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;html&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  How do you generate dynamic metadata for blog posts?
&lt;/h3&gt;

&lt;p&gt;Export &lt;code&gt;generateMetadata&lt;/code&gt; from the dynamic route and read &lt;code&gt;params&lt;/code&gt;, which is a Promise in current Next.js, so you must &lt;code&gt;await&lt;/code&gt; it. Return a per-post title, description, canonical URL and Open Graph article data. Next.js deduplicates the &lt;code&gt;fetch&lt;/code&gt; or cached data call, so the page and its metadata share one lookup.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/blog/[slug]/page.tsx&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Metadata&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;notFound&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next/navigation&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;getPost&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/lib/posts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Props&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;generateMetadata&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="nx"&gt;Props&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Metadata&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;slug&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getPost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;seoTitle&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;seoDescription&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;excerpt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;alternates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;canonical&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`/blog/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;openGraph&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;article&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;excerpt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;publishedTime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;publishedAt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
      &lt;span class="na"&gt;modifiedTime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;updatedAt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
      &lt;span class="na"&gt;authors&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Waseem Anjum&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
      &lt;span class="na"&gt;images&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;coverImage&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;coverImage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;900&lt;/span&gt; &lt;span class="p"&gt;}]&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;PostPage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="nx"&gt;Props&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;slug&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getPost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;notFound&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;article&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* render post */&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;article&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On this site, every post page ships exactly one canonical link, &lt;code&gt;index, follow&lt;/code&gt; robots meta, and RSS and Atom alternates. You can confirm it on the &lt;a href="https://www.fabwaseem.com/blog/coolify-vs-vercel" rel="noopener noreferrer"&gt;Coolify vs Vercel post&lt;/a&gt; by viewing the source.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you create a sitemap in Next.js?
&lt;/h2&gt;

&lt;p&gt;Add &lt;code&gt;app/sitemap.ts&lt;/code&gt; and default-export a function that returns &lt;code&gt;MetadataRoute.Sitemap&lt;/code&gt;, an array of URLs with optional &lt;code&gt;lastModified&lt;/code&gt;, &lt;code&gt;changeFrequency&lt;/code&gt;, &lt;code&gt;priority&lt;/code&gt;, &lt;code&gt;images&lt;/code&gt; and &lt;code&gt;alternates&lt;/code&gt;. Next.js serves it at &lt;code&gt;/sitemap.xml&lt;/code&gt;. Because it is a normal async function, you can query your database for every published post and project (&lt;a href="https://nextjs.org/docs/app/api-reference/file-conventions/metadata/sitemap" rel="noopener noreferrer"&gt;Next.js docs&lt;/a&gt;).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/sitemap.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;MetadataRoute&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;getPublishedPosts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;getPublishedProjects&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/lib/content&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;SITE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://www.fabwaseem.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;sitemap&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;MetadataRoute&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Sitemap&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;posts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;projects&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nf"&gt;getPublishedPosts&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nf"&gt;getPublishedProjects&lt;/span&gt;&lt;span class="p"&gt;()])&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SITE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;lastModified&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="na"&gt;images&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;SITE&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/opengraph-image`&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;SITE&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/projects`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;lastModified&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;SITE&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/blog`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;lastModified&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;projects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;SITE&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/projects/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;lastModified&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;updatedAt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;})),&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;posts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;SITE&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/blog/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;lastModified&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;updatedAt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;images&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;coverImage&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;coverImage&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;})),&lt;/span&gt;
  &lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep &lt;code&gt;lastModified&lt;/code&gt; honest. Use the real update time from your database rather than &lt;code&gt;new Date()&lt;/code&gt; on every URL, so crawlers can trust it. The live &lt;a href="https://www.fabwaseem.com/sitemap.xml" rel="noopener noreferrer"&gt;fabwaseem.com sitemap&lt;/a&gt; lists the home page, author page, every &lt;a href="https://www.fabwaseem.com/projects" rel="noopener noreferrer"&gt;project page&lt;/a&gt;, every post, and the category and tag archives, with image entries where they exist. For more than 50,000 URLs, split it with &lt;code&gt;generateSitemaps&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you allow AI crawlers in robots.txt with Next.js?
&lt;/h2&gt;

&lt;p&gt;Add &lt;code&gt;app/robots.ts&lt;/code&gt; returning &lt;code&gt;MetadataRoute.Robots&lt;/code&gt;. Give each crawler its own rule when you want different behavior, list your sitemap, and keep private routes like &lt;code&gt;/api/&lt;/code&gt; and &lt;code&gt;/admin&lt;/code&gt; disallowed. To be cited by AI search, allow the search crawlers: &lt;code&gt;OAI-SearchBot&lt;/code&gt;, &lt;code&gt;ChatGPT-User&lt;/code&gt;, &lt;code&gt;PerplexityBot&lt;/code&gt;, &lt;code&gt;Perplexity-User&lt;/code&gt; and &lt;code&gt;Claude-SearchBot&lt;/code&gt; (&lt;a href="https://nextjs.org/docs/app/api-reference/file-conventions/metadata/robots" rel="noopener noreferrer"&gt;Next.js docs&lt;/a&gt;).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/robots.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;MetadataRoute&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PRIVATE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/admin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/admin/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;AI_SEARCH&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;OAI-SearchBot&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ChatGPT-User&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PerplexityBot&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Perplexity-User&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Claude-SearchBot&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Claude-User&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;AI_TRAINING&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;GPTBot&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ClaudeBot&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Google-Extended&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Applebot-Extended&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;CCBot&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;BLOCKED&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Bytespider&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;robots&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nx"&gt;MetadataRoute&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Robots&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;userAgent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;*&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;allow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;disallow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PRIVATE&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;...[...&lt;/span&gt;&lt;span class="nx"&gt;AI_SEARCH&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;AI_TRAINING&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;userAgent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="nx"&gt;userAgent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;allow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;disallow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PRIVATE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;})),&lt;/span&gt;
      &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;BLOCKED&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;userAgent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;userAgent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;disallow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})),&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="na"&gt;sitemap&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://www.fabwaseem.com/sitemap.xml&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Which crawlers should you allow? It depends on whether you want visibility, training use, or both:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;User agent&lt;/th&gt;
&lt;th&gt;Operator&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;th&gt;fabwaseem.com&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Googlebot&lt;/td&gt;
&lt;td&gt;Google&lt;/td&gt;
&lt;td&gt;Search and AI Overviews&lt;/td&gt;
&lt;td&gt;Allowed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Google-Extended&lt;/td&gt;
&lt;td&gt;Google&lt;/td&gt;
&lt;td&gt;Gemini training and grounding (not Search)&lt;/td&gt;
&lt;td&gt;Allowed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OAI-SearchBot&lt;/td&gt;
&lt;td&gt;OpenAI&lt;/td&gt;
&lt;td&gt;ChatGPT search results&lt;/td&gt;
&lt;td&gt;Allowed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ChatGPT-User&lt;/td&gt;
&lt;td&gt;OpenAI&lt;/td&gt;
&lt;td&gt;Fetches a page when a user asks&lt;/td&gt;
&lt;td&gt;Allowed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPTBot&lt;/td&gt;
&lt;td&gt;OpenAI&lt;/td&gt;
&lt;td&gt;Model training&lt;/td&gt;
&lt;td&gt;Allowed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PerplexityBot&lt;/td&gt;
&lt;td&gt;Perplexity&lt;/td&gt;
&lt;td&gt;Perplexity search index&lt;/td&gt;
&lt;td&gt;Allowed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude-SearchBot&lt;/td&gt;
&lt;td&gt;Anthropic&lt;/td&gt;
&lt;td&gt;Claude search results&lt;/td&gt;
&lt;td&gt;Allowed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bytespider&lt;/td&gt;
&lt;td&gt;ByteDance&lt;/td&gt;
&lt;td&gt;Crawling&lt;/td&gt;
&lt;td&gt;Blocked&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two details trip people up. First, blocking &lt;code&gt;Google-Extended&lt;/code&gt; does not remove you from AI Overviews; Google says robots directives for Googlebot are the control for Search, and you limit snippets with &lt;code&gt;nosnippet&lt;/code&gt;, &lt;code&gt;max-snippet&lt;/code&gt; or &lt;code&gt;noindex&lt;/code&gt; (&lt;a href="https://developers.google.com/search/docs/appearance/ai-features" rel="noopener noreferrer"&gt;Google Search Central&lt;/a&gt;). Second, robots.txt is only half the story: a CDN or firewall bot rule that challenges these crawlers blocks them just as effectively, and Google explicitly lists checking "any CDN or hosting infrastructure" as a best practice.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you add JSON-LD structured data in Next.js?
&lt;/h2&gt;

&lt;p&gt;Render a native &lt;code&gt;&amp;lt;script type="application/ld+json"&amp;gt;&lt;/code&gt; tag inside your &lt;code&gt;page.tsx&lt;/code&gt; or &lt;code&gt;layout.tsx&lt;/code&gt;, with the object passed through &lt;code&gt;JSON.stringify&lt;/code&gt; and &lt;code&gt;&amp;lt;&lt;/code&gt; replaced by &lt;code&gt;\u003c&lt;/code&gt; to prevent XSS. The Next.js docs recommend this exact pattern and note that &lt;code&gt;next/script&lt;/code&gt; is not needed because JSON-LD is data, not executable code (&lt;a href="https://nextjs.org/docs/app/guides/json-ld" rel="noopener noreferrer"&gt;Next.js docs&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;A small reusable component keeps every page consistent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// components/json-ld.tsx&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Thing&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;WithContext&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;schema-dts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;JsonLd&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nx"&gt;Thing&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;WithContext&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;script&lt;/span&gt;
      &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"application/ld+json"&lt;/span&gt;
      &lt;span class="na"&gt;dangerouslySetInnerHTML&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;__html&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;lt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s1"&gt;u003c&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then build a &lt;code&gt;BlogPosting&lt;/code&gt; from the same record that renders the page, so the markup always matches the visible text:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/blog/[slug]/page.tsx (inside PostPage)&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;BlogPosting&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;WithContext&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;schema-dts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;JsonLd&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/components/json-ld&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`https://www.fabwaseem.com/blog/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;blogPosting&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;WithContext&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;BlogPosting&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@context&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://schema.org&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;BlogPosting&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headline&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;excerpt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;coverImage&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;datePublished&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;publishedAt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;dateModified&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;updatedAt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;mainEntityOfPage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;author&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Person&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Waseem Anjum&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://www.fabwaseem.com/author/waseem-anjum&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;keywords&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;, &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;article&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;JsonLd&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;blogPosting&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* ... */&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;article&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Which schema types matter for a developer blog?
&lt;/h3&gt;

&lt;p&gt;Each post on this site emits &lt;code&gt;BlogPosting&lt;/code&gt; (also typed &lt;code&gt;TechArticle&lt;/code&gt;), &lt;code&gt;BreadcrumbList&lt;/code&gt; and &lt;code&gt;FAQPage&lt;/code&gt;, and tutorial posts add &lt;code&gt;HowTo&lt;/code&gt;. Project pages describe each project as a &lt;code&gt;SoftwareApplication&lt;/code&gt; with breadcrumbs, and the author page uses &lt;code&gt;Person&lt;/code&gt;. Google says structured data helps it "understand the content of the page" (&lt;a href="https://developers.google.com/search/docs/appearance/structured-data/intro-structured-data" rel="noopener noreferrer"&gt;Google Search Central&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Be realistic about rich results. Since 2023 Google only shows FAQ rich results for well-known, authoritative government and health sites (&lt;a href="https://developers.google.com/search/docs/appearance/structured-data/faqpage" rel="noopener noreferrer"&gt;FAQPage docs&lt;/a&gt;). FAQ markup is still useful because it labels question and answer pairs cleanly for any parser, but do not expect the expandable FAQ snippet on a personal blog. Google's AI features page also says there's "no special schema.org structured data that you need to add" for AI Overviews, so treat JSON-LD as clarity, not a ranking switch.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is llms.txt and should a Next.js site have one?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;llms.txt&lt;/code&gt; is a proposed standard: a Markdown file at &lt;code&gt;/llms.txt&lt;/code&gt; that gives language models a concise summary of your site and links to the most useful pages. The spec asks for an H1 with the site name, a blockquote summary, then sections of links (&lt;a href="https://llmstxt.org/" rel="noopener noreferrer"&gt;llmstxt.org&lt;/a&gt;). It is cheap to add, but Google says it does not need it for AI features.&lt;/p&gt;

&lt;p&gt;That second point matters for setting expectations. Google states: "You don't need to create new machine readable files, AI text files, or markup to appear in these features" (&lt;a href="https://developers.google.com/search/docs/appearance/ai-features" rel="noopener noreferrer"&gt;Google Search Central&lt;/a&gt;). So &lt;code&gt;llms.txt&lt;/code&gt; will not help with AI Overviews. It is useful for coding assistants and agents that fetch it directly, and the Next.js docs themselves publish one. This site serves both a short &lt;a href="https://www.fabwaseem.com/llms.txt" rel="noopener noreferrer"&gt;llms.txt&lt;/a&gt; and an expanded &lt;code&gt;llms-full.txt&lt;/code&gt; generated from the same database as the pages.&lt;/p&gt;

&lt;p&gt;In the App Router, a route handler builds it at request time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/llms.txt/route.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;getPublishedPosts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;getPublishedProjects&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/lib/content&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;SITE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://www.fabwaseem.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;revalidate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3600&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;GET&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;posts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;projects&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nf"&gt;getPublishedPosts&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nf"&gt;getPublishedProjects&lt;/span&gt;&lt;span class="p"&gt;()])&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;# Waseem Anjum&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;gt; Senior full stack developer building with Next.js, React, Node.js, NestJS and TypeScript.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;## Projects&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;projects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`- [&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;](&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;SITE&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/projects/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;): &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;## Blog&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;posts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`- [&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;](&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;SITE&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/blog/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;): &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;excerpt&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;## Optional&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s2"&gt;`- [Full context](&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;SITE&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/llms-full.txt)`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text/plain; charset=utf-8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  How do you write content that ChatGPT and Perplexity will cite?
&lt;/h2&gt;

&lt;p&gt;Write in self-contained passages that answer one question each. Phrase headings as the questions people ask, put a 40 to 60 word direct answer right under each heading, define terms in one sentence, and back claims with links to primary sources. Retrieval systems pull passages, not whole pages, so every section should make sense on its own.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl5yxloxlz11lbwo1fcup.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl5yxloxlz11lbwo1fcup.jpg" alt="Anatomy of a citable section: question heading, direct 40-60 word answer, supporting detail with table or code, and a cited source link" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is the structure every post on this blog follows:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Lead with a definition.&lt;/strong&gt; The first two sentences say what the thing is and who it's for. It gives engines a sentence that stands on its own.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use question headings.&lt;/strong&gt; Match the wording people type or say, such as "How do you create a sitemap in Next.js?". Query fan-out means engines look for answers to many sub-questions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Answer first, explain second.&lt;/strong&gt; One short paragraph that fully answers the heading, then detail, code and caveats.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prefer tables and numbered steps.&lt;/strong&gt; Comparisons and procedures extract cleanly into generated answers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cite primary sources inline.&lt;/strong&gt; Link the official docs for every factual claim. It shows the claim is checkable and helps readers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never invent numbers.&lt;/strong&gt; If you can't source a statistic, leave it out. Made-up stats are the fastest way to lose trust.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Show who wrote it.&lt;/strong&gt; A real author with a profile page and &lt;code&gt;Person&lt;/code&gt; markup supports Google's guidance on &lt;a href="https://developers.google.com/search/docs/fundamentals/creating-helpful-content" rel="noopener noreferrer"&gt;helpful, people-first content&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add a TL;DR, key takeaways and FAQs.&lt;/strong&gt; They give engines short, quotable summaries that match how people ask.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Does page speed or rendering affect AI citations?
&lt;/h3&gt;

&lt;p&gt;Yes, indirectly. Many AI crawlers don't run JavaScript the way a browser does, so content that only appears after client-side rendering may be invisible to them. Server Components render your article to HTML on the server by default, which is one of the strongest reasons to build content sites on the App Router.&lt;/p&gt;

&lt;p&gt;Keep the article body in Server Components and reserve &lt;code&gt;'use client'&lt;/code&gt; for interactive pieces such as a share button or theme switcher. Check what crawlers see with &lt;code&gt;curl https://your-site.com/blog/post | grep "&amp;lt;h2"&lt;/code&gt;. If your headings aren't in the raw HTML, fix that before anything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you get new pages indexed faster?
&lt;/h2&gt;

&lt;p&gt;Submit your sitemap in Google Search Console and Bing Webmaster Tools, link new posts from existing pages, and ping IndexNow when you publish. IndexNow is a protocol supported by Bing and other engines that lets you notify them of changed URLs with one request (&lt;a href="https://www.indexnow.org/documentation" rel="noopener noreferrer"&gt;IndexNow docs&lt;/a&gt;). It takes one request per publish, so it's worth the small effort.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// lib/indexnow.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;pingIndexNow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;urls&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;INDEXNOW_KEY&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://api.indexnow.org/indexnow&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json; charset=utf-8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;www.fabwaseem.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;keyLocation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`https://www.fabwaseem.com/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.txt`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;urlList&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;urls&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Also publish an RSS or Atom feed (this blog serves &lt;code&gt;/blog/rss.xml&lt;/code&gt; and &lt;code&gt;/blog/atom.xml&lt;/code&gt;) so aggregators and feed-reading agents pick up new posts quickly. Hosting matters less than people think: this site self-hosts on a VPS with Coolify, covered in &lt;a href="https://www.fabwaseem.com/blog/self-host-nextjs-with-coolify" rel="noopener noreferrer"&gt;how to self-host Next.js with Coolify&lt;/a&gt;, and every feature here works the same on a single &lt;code&gt;next start&lt;/code&gt; server.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next.js SEO checklist for AI search
&lt;/h2&gt;

&lt;p&gt;Use this as a pre-launch checklist. Every item is something fabwaseem.com ships today.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Area&lt;/th&gt;
&lt;th&gt;What to ship&lt;/th&gt;
&lt;th&gt;Next.js feature&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Titles and descriptions&lt;/td&gt;
&lt;td&gt;Unique per page, title template&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;metadata&lt;/code&gt;, &lt;code&gt;generateMetadata&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Canonical URLs&lt;/td&gt;
&lt;td&gt;One per page, absolute&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;metadataBase&lt;/code&gt;, &lt;code&gt;alternates.canonical&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Social previews&lt;/td&gt;
&lt;td&gt;Open Graph and Twitter images&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;opengraph-image.tsx&lt;/code&gt;, &lt;code&gt;openGraph&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Discovery&lt;/td&gt;
&lt;td&gt;Sitemap with real &lt;code&gt;lastModified&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;app/sitemap.ts&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Crawl access&lt;/td&gt;
&lt;td&gt;AI search bots allowed, private routes blocked&lt;/td&gt;
&lt;td&gt;&lt;code&gt;app/robots.ts&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Understanding&lt;/td&gt;
&lt;td&gt;BlogPosting, Breadcrumb, Person, FAQPage&lt;/td&gt;
&lt;td&gt;JSON-LD in &lt;code&gt;page.tsx&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LLM context&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;llms.txt&lt;/code&gt; and &lt;code&gt;llms-full.txt&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Route handlers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rendering&lt;/td&gt;
&lt;td&gt;Article HTML on first response&lt;/td&gt;
&lt;td&gt;Server Components&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Freshness&lt;/td&gt;
&lt;td&gt;RSS/Atom feeds, IndexNow ping&lt;/td&gt;
&lt;td&gt;Route handlers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Content&lt;/td&gt;
&lt;td&gt;Question headings, direct answers, sources&lt;/td&gt;
&lt;td&gt;Your writing&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you're still choosing where to host, the trade-offs are in &lt;a href="https://www.fabwaseem.com/blog/coolify-vs-vercel" rel="noopener noreferrer"&gt;Coolify vs Vercel for Next.js&lt;/a&gt;. SEO works equally well on either.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Is Next.js good for SEO?
&lt;/h3&gt;

&lt;p&gt;Yes. The App Router renders pages on the server by default, so crawlers receive full HTML without running JavaScript. It also has a built-in Metadata API for titles, descriptions, canonicals and Open Graph tags, plus file conventions for sitemap.xml, robots.txt and Open Graph images. Good SEO still depends on your content and internal linking.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need llms.txt to appear in ChatGPT or Google AI Overviews?
&lt;/h3&gt;

&lt;p&gt;No. Google says you don't need new machine-readable files, AI text files or special markup to appear in AI Overviews or AI Mode. llms.txt is a proposed standard that some agents and coding assistants read directly. It's cheap to add as a route handler, but treat it as a bonus, not a ranking factor.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which robots.txt user agents control ChatGPT and Perplexity?
&lt;/h3&gt;

&lt;p&gt;OpenAI uses OAI-SearchBot for ChatGPT search, ChatGPT-User for pages fetched during a conversation and GPTBot for training. Perplexity uses PerplexityBot for its index and Perplexity-User for user-triggered fetches. To be cited in their answers, allow the search and user agents; blocking GPTBot only affects training.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does blocking Google-Extended remove my site from AI Overviews?
&lt;/h3&gt;

&lt;p&gt;No. Google-Extended controls use in Gemini training and grounding in some other Google systems, not Search. AI Overviews and AI Mode are part of Search, so they follow Googlebot's robots rules. To limit what appears, use nosnippet, data-nosnippet, max-snippet or noindex on the page.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I add JSON-LD in the Next.js App Router?
&lt;/h3&gt;

&lt;p&gt;Render a script tag with type application/ld+json inside page.tsx or layout.tsx, and pass the object through JSON.stringify with every &amp;lt; replaced by \u003c to prevent XSS. The Next.js docs recommend a native script tag rather than next/script. You can type the object with the schema-dts package.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does FAQ schema still help SEO in 2026?
&lt;/h3&gt;

&lt;p&gt;FAQ rich results are now limited to well-known, authoritative government and health websites, so most blogs won't get the expandable FAQ snippet. FAQPage markup is still valid structured data that labels question and answer pairs clearly, and visible FAQs written as real questions give AI engines short, quotable answers.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.fabwaseem.com/blog/nextjs-seo-ai-search" rel="noopener noreferrer"&gt;fabwaseem.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>seo</category>
      <category>ai</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Sign-In with Ethereum in Next.js 16: SIWE with viem, iron-session and a Replay-Proof Nonce (Tested)</title>
      <dc:creator>Waseem Anjum</dc:creator>
      <pubDate>Tue, 27 Oct 2026 19:09:38 +0000</pubDate>
      <link>https://dev.to/fabwaseem/sign-in-with-ethereum-in-nextjs-16-siwe-with-viem-iron-session-and-a-replay-proof-nonce-tested-3lf4</link>
      <guid>https://dev.to/fabwaseem/sign-in-with-ethereum-in-nextjs-16-siwe-with-viem-iron-session-and-a-replay-proof-nonce-tested-3lf4</guid>
      <description>&lt;p&gt;&lt;em&gt;Cross-posted from my blog. The original, with any updates, lives on &lt;a href="https://www.fabwaseem.com/blog/sign-in-with-ethereum-nextjs" rel="noopener noreferrer"&gt;fabwaseem.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sign-In with Ethereum (SIWE)&lt;/strong&gt; lets a user log in by signing a plain-text message with their wallet, as defined in &lt;a href="https://eips.ethereum.org/EIPS/eip-4361" rel="noopener noreferrer"&gt;EIP-4361&lt;/a&gt;. In Next.js you need three pieces: a route that issues a nonce, a route that verifies the signed message, and a session cookie. viem ships every SIWE helper you need, so no extra &lt;code&gt;siwe&lt;/code&gt; package is required.&lt;/p&gt;

&lt;p&gt;I built the whole flow in a Next.js 16.4.3 app with viem 2.57.11 and iron-session 9.0.1, then attacked it. Two things surprised me. The common "keep the nonce in the session cookie" pattern let me &lt;strong&gt;replay a captured login&lt;/strong&gt; with HTTP 200. And with viem's default verification mode, every login waited &lt;strong&gt;about 1.06 seconds&lt;/strong&gt; when the RPC endpoint was down, against 5.9 ms with one option changed. Both fixes are below.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Create the message with &lt;code&gt;createSiweMessage&lt;/code&gt;, sign it in the wallet, and verify it on the server with viem's SIWE helpers. Always pass the expected &lt;code&gt;domain&lt;/code&gt; and &lt;code&gt;nonce&lt;/code&gt;. Without them, verification only checks the signature.&lt;/li&gt;
&lt;li&gt;Store nonces &lt;strong&gt;server-side&lt;/strong&gt; and delete them on first use (Redis &lt;code&gt;SET NX EX&lt;/code&gt; then &lt;code&gt;GETDEL&lt;/code&gt;). A nonce that lives only in the cookie can be replayed with the old cookie.&lt;/li&gt;
&lt;li&gt;Verify plain wallets locally with &lt;code&gt;mode: "eoa"&lt;/code&gt;. Keep an RPC for smart-contract wallets (ERC-1271 and ERC-6492), but don't make every login wait on it.&lt;/li&gt;
&lt;li&gt;Hard-code the domain from config, never from the request's &lt;code&gt;Host&lt;/code&gt; header.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Login verify, 50 runs on the box&lt;/th&gt;
&lt;th&gt;p50&lt;/th&gt;
&lt;th&gt;p95&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;verifySiweMessage&lt;/code&gt;, default mode, RPC unreachable&lt;/td&gt;
&lt;td&gt;1,064 ms&lt;/td&gt;
&lt;td&gt;1,077 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;verifySiweMessage&lt;/code&gt;, default mode, public RPC&lt;/td&gt;
&lt;td&gt;24.2 ms&lt;/td&gt;
&lt;td&gt;33.6 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;verifyHash&lt;/code&gt; with &lt;code&gt;mode: "eoa"&lt;/code&gt;, RPC unreachable&lt;/td&gt;
&lt;td&gt;5.9 ms&lt;/td&gt;
&lt;td&gt;8.9 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;verifyHash&lt;/code&gt; with &lt;code&gt;mode: "eoa"&lt;/code&gt;, public RPC&lt;/td&gt;
&lt;td&gt;5.1 ms&lt;/td&gt;
&lt;td&gt;6.5 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Times are for the whole &lt;code&gt;POST /api/siwe/verify&lt;/code&gt; request against &lt;code&gt;next start&lt;/code&gt;, measured from a Node client on the same machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Sign-In with Ethereum works
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;The browser asks your server for a &lt;strong&gt;nonce&lt;/strong&gt;, a random single-use value.&lt;/li&gt;
&lt;li&gt;The browser builds an EIP-4361 message containing your domain, the wallet address, the nonce, a chain ID and timestamps, and asks the wallet to sign it (&lt;code&gt;personal_sign&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;The server checks that the message names &lt;strong&gt;your&lt;/strong&gt; domain, contains the nonce it issued, hasn't expired, and was signed by the address in the message. Then it starts a session.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This is what a signed message looks like, from my test run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;app.example.com wants you to sign in with your Ethereum account:
0x13F7713E3A0499BF531879888D8E6dd5f93FDea7

Sign in to Example.

URI: https://app.example.com/login
Version: 1
Chain ID: 1
Nonce: 6a5d8a5d6a69ad7863e7c51413d00d684959afce06879effaa9e1c6e9cc86e84ff0f0ed03cf62b106dab75ac0d37c66f
Issued At: 2026-10-26T05:10:48.993Z
Expiration Time: 2026-10-26T05:15:48.993Z
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The domain line is what protects users from phishing. Many wallets warn the user when it doesn't match the site asking for the signature, and your server must reject a message signed for any other domain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setup
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm i viem iron-session
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// lib/session.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;cookies&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;next/headers&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;getIronSession&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;SessionOptions&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;iron-session&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;SiweSession&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;nonce&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;address&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="s2"&gt;`0x&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;chainId&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sessionOptions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SessionOptions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;password&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SESSION_SECRET&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// at least 32 characters&lt;/span&gt;
  &lt;span class="na"&gt;cookieName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;siwe_session&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;cookieOptions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;httpOnly&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;sameSite&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;lax&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;secure&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NODE_ENV&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;production&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getSession&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;getIronSession&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;SiweSession&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;cookies&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nx"&gt;sessionOptions&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// lib/siwe.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createPublicClient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;http&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;viem&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;mainnet&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;viem/chains&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Only needed for smart-contract wallets (ERC-1271 / ERC-6492).&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;publicClient&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createPublicClient&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;chain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;mainnet&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;transport&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;http&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;RPC_URL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// From config, never from the request's Host header.&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;SIWE_DOMAIN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SIWE_DOMAIN&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// e.g. "app.example.com"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;iron-session 9 is ESM-only and needs Node 22.13 or later, according to its README. I ran everything on Node 22.20. The sealed session cookie in my test was 342 bytes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: issue a nonce (and remember it on the server)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// lib/nonce-store.ts&lt;/span&gt;
&lt;span class="c1"&gt;// In production use Redis: SET siwe:&amp;lt;nonce&amp;gt; 1 EX 300 NX on issue, GETDEL on use.&lt;/span&gt;
&lt;span class="c1"&gt;// A Map works for one process and for tests.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;issued&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;issueNonce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nonce&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ttlMs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;issued&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nonce&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;ttlMs&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;consumeNonce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nonce&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;exp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;issued&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nonce&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;issued&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nonce&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// single use, even if verification fails&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;exp&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;exp&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/api/siwe/nonce/route.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;generateSiweNonce&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;viem/siwe&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;getSession&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/lib/session&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;issueNonce&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/lib/nonce-store&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;GET&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getSession&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nonce&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;generateSiweNonce&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;issueNonce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nonce&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;nonce&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nonce&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Cache-Control&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;no-store&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;generateSiweNonce()&lt;/code&gt; returned a 96-character hex string in my test. EIP-4361 only requires at least 8 alphanumeric characters, so that is plenty.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: sign in the browser
&lt;/h2&gt;

&lt;p&gt;With wagmi 3 (3.7.9 when I wrote this) you already have a connected address and chain. &lt;code&gt;useConnection&lt;/code&gt; replaces &lt;code&gt;useAccount&lt;/code&gt;, and mutation hooks now return &lt;code&gt;mutateAsync&lt;/code&gt;. Build the message with viem and ask the wallet to sign it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// components/sign-in-button.tsx&lt;/span&gt;
&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;use client&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createSiweMessage&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;viem/siwe&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useConnection&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useSignMessage&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;wagmi&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;SignInButton&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;address&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;chainId&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useConnection&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// useAccount() in wagmi 2&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;mutateAsync&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;signMessage&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useSignMessage&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;signIn&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;address&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;chainId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;nonce&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/api/siwe/nonce&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createSiweMessage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;host&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;address&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;statement&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Sign in to Example.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;chainId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;nonce&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;issuedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
      &lt;span class="na"&gt;expirationTime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;signature&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;signMessage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/api/siwe/verify&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;content-type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;signature&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Sign-in failed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt; &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;signIn&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;disabled&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;address&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Sign in with Ethereum&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The client may put any domain it likes in the message. That is fine, because the server decides which domain it accepts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: verify on the server
&lt;/h2&gt;

&lt;p&gt;The simplest version uses &lt;code&gt;verifySiweMessage&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/api/siwe/verify/route.ts (simple version)&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;parseSiweMessage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;verifySiweMessage&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;viem/siwe&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;getSession&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/lib/session&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;consumeNonce&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/lib/nonce-store&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;publicClient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;SIWE_DOMAIN&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/lib/siwe&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;POST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;signature&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getSession&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nonce&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nonce&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;nonce&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;consumeNonce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nonce&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;nonce used or expired&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nonce&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;verifySiweMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;publicClient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SIWE_DOMAIN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;nonce&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;address&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;chainId&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseSiweMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;address&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;address&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;chainId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;chainId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;address&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I tested what &lt;code&gt;verifySiweMessage&lt;/code&gt; accepts and rejects with a fresh private key:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Case&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Valid message and signature&lt;/td&gt;
&lt;td&gt;&lt;code&gt;true&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Different nonce passed to verify&lt;/td&gt;
&lt;td&gt;&lt;code&gt;false&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Different domain passed to verify (phishing site)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;false&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Checked 6 minutes later, message expired after 5&lt;/td&gt;
&lt;td&gt;&lt;code&gt;false&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Message signed by another key&lt;/td&gt;
&lt;td&gt;&lt;code&gt;false&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One character of the statement changed&lt;/td&gt;
&lt;td&gt;&lt;code&gt;false&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Valid signature, no &lt;code&gt;domain&lt;/code&gt; or &lt;code&gt;nonce&lt;/code&gt; passed&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;true&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The last row matters. The &lt;code&gt;domain&lt;/code&gt; and &lt;code&gt;nonce&lt;/code&gt; parameters are optional, and when you leave them out the function only proves that the address signed some SIWE message. That message could have been signed for another site. Always pass both.&lt;/p&gt;

&lt;h2&gt;
  
  
  The replay bug: a nonce in the cookie isn't single-use
&lt;/h2&gt;

&lt;p&gt;Many tutorials store the nonce only in the session cookie and clear it after login. I tested that version (the same route without &lt;code&gt;consumeNonce&lt;/code&gt;):&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;GET /api/siwe/nonce&lt;/code&gt; returns a cookie containing the nonce.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;POST /api/siwe/verify&lt;/code&gt; with that cookie, message and signature returns &lt;strong&gt;200&lt;/strong&gt;, and the response sets a new cookie without the nonce.&lt;/li&gt;
&lt;li&gt;Sending the same request again &lt;strong&gt;with the original cookie&lt;/strong&gt; returned &lt;strong&gt;200&lt;/strong&gt; again: a second valid session from one signature.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Clearing the nonce only changes the &lt;em&gt;new&lt;/em&gt; cookie. The old cookie is still a valid, sealed copy of the session, and the server has no memory that it was used. Anyone who captured the request (a proxy log, a compromised browser extension, a shared machine) could mint sessions until the message expired. With the server-side store, the same replay returned &lt;strong&gt;401&lt;/strong&gt; &lt;code&gt;nonce used or expired&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;In production, back &lt;code&gt;issueNonce&lt;/code&gt; and &lt;code&gt;consumeNonce&lt;/code&gt; with Redis so every instance sees the same state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// lib/nonce-store.ts (Redis)&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Redis&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ioredis&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;redis&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Redis&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;REDIS_URL&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;issueNonce&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nonce&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`siwe:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;nonce&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;EX&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;NX&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;consumeNonce&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nonce&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getdel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`siwe:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;nonce&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;GETDEL&lt;/code&gt; (Redis 6.2+) reads and deletes in one atomic step, so two parallel requests can't both use the same nonce. A Postgres table with &lt;code&gt;DELETE ... RETURNING&lt;/code&gt; works the same way if you don't run Redis.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 1-second login: verify plain wallets locally
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;verifySiweMessage&lt;/code&gt; supports smart-contract wallets through ERC-6492, so by default it first verifies on-chain with an &lt;code&gt;eth_call&lt;/code&gt;. Only when that call fails does it fall back to recovering the address locally. In my test:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;With a public RPC, a login took 24.2 ms at p50, mostly the RPC round trip.&lt;/li&gt;
&lt;li&gt;With the RPC unreachable, each login took &lt;strong&gt;1,064 ms&lt;/strong&gt;. My offline test client counted 4 RPC attempts per verification, matching viem's transport default of 3 retries, before the local fallback succeeded.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;viem's lower-level &lt;code&gt;verifyHash&lt;/code&gt; has a &lt;code&gt;mode: "eoa"&lt;/code&gt; option that tries local ECDSA recovery first and only goes on-chain if that fails. &lt;code&gt;verifySiweMessage&lt;/code&gt;'s TypeScript type doesn't accept &lt;code&gt;mode&lt;/code&gt; (true in 2.57.11, which I tested, and still true in 2.57.12, the latest release on October 27, 2026), so I rebuilt the same checks from viem's exported helpers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// lib/verify.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;hashMessage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Hex&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;viem&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;verifyHash&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;viem/actions&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;parseSiweMessage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;validateSiweMessage&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;viem/siwe&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;publicClient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;SIWE_DOMAIN&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/lib/siwe&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;verifySiwe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Hex&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;nonce&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;parsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseSiweMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;address&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// checks domain, nonce and the time window (expirationTime / notBefore)&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;validateSiweMessage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SIWE_DOMAIN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;nonce&lt;/span&gt; &lt;span class="p"&gt;}))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;verifyHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;publicClient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;address&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;address&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;hashMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nx"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;eoa&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// local ecrecover first, RPC only for contract wallets&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;ok&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;parsed&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Swap &lt;code&gt;verifySiweMessage&lt;/code&gt; for &lt;code&gt;verifySiwe&lt;/code&gt; in the route and logins took 5.1 ms (public RPC) and 5.9 ms (RPC down) at p50. Smart-contract wallets still verify on-chain, so keep a reliable RPC for them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading the session
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/api/me/route.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;getSession&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/lib/session&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;GET&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;address&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getSession&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;address&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;address&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;signed out&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For pages, call &lt;code&gt;getSession()&lt;/code&gt; in a Server Component or check the cookie in &lt;code&gt;proxy.ts&lt;/code&gt; (the Next.js 16 name for middleware). If you also need email or OAuth logins, &lt;a href="https://www.fabwaseem.com/blog/better-auth-nextjs" rel="noopener noreferrer"&gt;Better Auth with Next.js 16&lt;/a&gt; covers that side, and SIWE can sit beside it as one more sign-in method.&lt;/p&gt;

&lt;h2&gt;
  
  
  Security checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Domain from config.&lt;/strong&gt; Compare against a fixed &lt;code&gt;SIWE_DOMAIN&lt;/code&gt;. Reading it from the &lt;code&gt;Host&lt;/code&gt; header lets a proxy or a forged header decide what you accept.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Single-use nonces on the server&lt;/strong&gt;, deleted on first use, with a short TTL (5 minutes is plenty).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Short expiry.&lt;/strong&gt; Set &lt;code&gt;expirationTime&lt;/code&gt; on the message. viem's validation rejects expired messages, as my 6-minute test showed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rate-limit&lt;/strong&gt; the nonce and verify routes per IP, like any login endpoint.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat the address as an identity, not a permission.&lt;/strong&gt; Look up roles in your database after login.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sign out by destroying the session&lt;/strong&gt; (&lt;code&gt;session.destroy()&lt;/code&gt;). Remember that a stateless cookie stays valid until it expires, so keep the session TTL modest or track session IDs server-side if you need instant revocation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For the API side of the same idea (input validation, SSRF and auth checks), see &lt;a href="https://www.fabwaseem.com/blog/ssrf-protection-nodejs" rel="noopener noreferrer"&gt;SSRF protection in Node.js&lt;/a&gt; and the &lt;a href="https://www.fabwaseem.com/blog/secure-nestjs-prisma-api-starter" rel="noopener noreferrer"&gt;secure NestJS + Prisma API starter&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test setup
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Next.js 16.4.3 (&lt;code&gt;next build&lt;/code&gt; + &lt;code&gt;next start&lt;/code&gt;), React 19.3.1, viem 2.57.11, iron-session 9.0.1, Node 22.20.0, on an 8 vCPU Linux box.&lt;/li&gt;
&lt;li&gt;Wallets: fresh keys from &lt;code&gt;generatePrivateKey()&lt;/code&gt;, signing with &lt;code&gt;account.signMessage&lt;/code&gt; (the same EIP-191 &lt;code&gt;personal_sign&lt;/code&gt; a browser wallet uses).&lt;/li&gt;
&lt;li&gt;"RPC unreachable" pointed &lt;code&gt;RPC_URL&lt;/code&gt; at a closed local port. "Public RPC" used a free public Ethereum mainnet endpoint.&lt;/li&gt;
&lt;li&gt;Each latency row is 50 timed logins after 2 warm-ups. I did not test real smart-contract wallets.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.fabwaseem.com/blog/sign-in-with-ethereum-nextjs" rel="noopener noreferrer"&gt;fabwaseem.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>web3</category>
      <category>ethereum</category>
      <category>security</category>
    </item>
    <item>
      <title>Production Next.js Dockerfile: Multi-Stage, Standalone and Non-Root</title>
      <dc:creator>Waseem Anjum</dc:creator>
      <pubDate>Mon, 26 Oct 2026 22:03:36 +0000</pubDate>
      <link>https://dev.to/fabwaseem/production-nextjs-dockerfile-multi-stage-standalone-and-non-root-e2j</link>
      <guid>https://dev.to/fabwaseem/production-nextjs-dockerfile-multi-stage-standalone-and-non-root-e2j</guid>
      <description>&lt;p&gt;&lt;em&gt;Cross-posted from my blog. The original, with any updates, lives on &lt;a href="https://www.fabwaseem.com/blog/nextjs-dockerfile" rel="noopener noreferrer"&gt;fabwaseem.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;production Next.js Dockerfile&lt;/strong&gt; is a multi-stage build that installs dependencies, runs &lt;code&gt;next build&lt;/code&gt; with &lt;code&gt;output: 'standalone'&lt;/code&gt;, and copies only the traced server, static assets and &lt;code&gt;public/&lt;/code&gt; folder into a small runtime image that runs &lt;code&gt;node server.js&lt;/code&gt; as a non-root user. The result is an image that starts fast, carries no dev dependencies or source code, and runs the same way on any Docker host.&lt;/p&gt;

&lt;p&gt;This guide gives you a complete, copy-paste Next.js Dockerfile for &lt;strong&gt;Next.js 16 and Node.js 24 LTS&lt;/strong&gt;, explains every line, and shows how to handle the parts that usually go wrong: &lt;code&gt;sharp&lt;/code&gt; for image optimization, build-time vs runtime environment variables, health checks, and file permissions. I built and ran this exact Dockerfile against a fresh &lt;code&gt;create-next-app&lt;/code&gt; project before writing this, and the image sizes below are measured, not estimated.&lt;/p&gt;

&lt;p&gt;If you deploy with Coolify, this Dockerfile drops straight into the Dockerfile build pack. For the platform side (server setup, domains, databases), see &lt;a href="https://www.fabwaseem.com/blog/self-host-nextjs-with-coolify" rel="noopener noreferrer"&gt;How to Self-Host Next.js with Coolify&lt;/a&gt;; this post focuses on the image itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is the best Dockerfile for Next.js in production?
&lt;/h2&gt;

&lt;p&gt;The best production Dockerfile for Next.js uses three stages (deps, builder, runner), enables &lt;code&gt;output: 'standalone'&lt;/code&gt; in &lt;code&gt;next.config&lt;/code&gt;, starts from an official Node.js LTS image such as &lt;code&gt;node:24-slim&lt;/code&gt;, copies &lt;code&gt;.next/standalone&lt;/code&gt;, &lt;code&gt;.next/static&lt;/code&gt; and &lt;code&gt;public/&lt;/code&gt; into the final stage, switches to the built-in &lt;code&gt;node&lt;/code&gt; user, and starts with &lt;code&gt;node server.js&lt;/code&gt; instead of &lt;code&gt;next start&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That is also the shape of the official &lt;a href="https://github.com/vercel/next.js/tree/canary/examples/with-docker" rel="noopener noreferrer"&gt;vercel/next.js with-docker example&lt;/a&gt;, which currently pins Node 24 slim. The version below keeps that structure and adds a health check, build cache mounts and a clear split between public and secret environment variables.&lt;/p&gt;

&lt;h3&gt;
  
  
  Quick definitions
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Term&lt;/th&gt;
&lt;th&gt;What it means&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Multi-stage build&lt;/td&gt;
&lt;td&gt;One Dockerfile with several &lt;code&gt;FROM&lt;/code&gt; stages; only the last stage ships. Build tools stay behind.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;output: 'standalone'&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Next.js traces which files each route needs and copies them, plus a minimal &lt;code&gt;server.js&lt;/code&gt;, into &lt;code&gt;.next/standalone&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Output file tracing&lt;/td&gt;
&lt;td&gt;The &lt;code&gt;@vercel/nft&lt;/code&gt; analysis Next.js runs during &lt;code&gt;next build&lt;/code&gt; to find every file a page can load.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Runner stage&lt;/td&gt;
&lt;td&gt;The final, small image that actually runs in production.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;HEALTHCHECK&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A Dockerfile instruction that tells Docker how to test whether the container is still working.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  TL;DR: the production Next.js Dockerfile
&lt;/h2&gt;

&lt;p&gt;Enable standalone output first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// next.config.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;NextConfig&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;next&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nextConfig&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NextConfig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;output&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;standalone&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nx"&gt;nextConfig&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then add this &lt;code&gt;Dockerfile&lt;/code&gt; at the project root:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="c"&gt;# syntax=docker/dockerfile:1&lt;/span&gt;
&lt;span class="k"&gt;ARG&lt;/span&gt;&lt;span class="s"&gt; NODE_VERSION=24-slim&lt;/span&gt;

&lt;span class="c"&gt;# 1) Install dependencies (cached until the lockfile changes)&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;node:${NODE_VERSION}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;deps&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; package.json package-lock.json ./&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;&lt;span class="nt"&gt;--mount&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;cache,target&lt;span class="o"&gt;=&lt;/span&gt;/root/.npm npm ci &lt;span class="nt"&gt;--no-audit&lt;/span&gt; &lt;span class="nt"&gt;--no-fund&lt;/span&gt;

&lt;span class="c"&gt;# 2) Build the app with output: "standalone"&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;node:${NODE_VERSION}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;builder&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; NODE_ENV=production&lt;/span&gt;
&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; NEXT_TELEMETRY_DISABLED=1&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=deps /app/node_modules ./node_modules&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . .&lt;/span&gt;
&lt;span class="c"&gt;# Public, build-time values only. They are inlined into the JS bundle.&lt;/span&gt;
&lt;span class="k"&gt;ARG&lt;/span&gt;&lt;span class="s"&gt; NEXT_PUBLIC_SITE_URL&lt;/span&gt;
&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL}&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;npm run build

&lt;span class="c"&gt;# 3) Minimal runtime image&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;node:${NODE_VERSION}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;runner&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; NODE_ENV=production \&lt;/span&gt;
    NEXT_TELEMETRY_DISABLED=1 \
    PORT=3000 \
    HOSTNAME=0.0.0.0

&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=builder --chown=node:node /app/public ./public&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; .next &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;chown &lt;/span&gt;node:node .next
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=builder --chown=node:node /app/.next/standalone ./&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=builder --chown=node:node /app/.next/static ./.next/static&lt;/span&gt;

&lt;span class="k"&gt;USER&lt;/span&gt;&lt;span class="s"&gt; node&lt;/span&gt;
&lt;span class="k"&gt;EXPOSE&lt;/span&gt;&lt;span class="s"&gt; 3000&lt;/span&gt;

&lt;span class="k"&gt;HEALTHCHECK&lt;/span&gt;&lt;span class="s"&gt; --interval=30s --timeout=5s --start-period=20s --retries=3 \&lt;/span&gt;
  CMD ["node", "-e", "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/api/health').then(r=&amp;gt;process.exit(r.ok?0:1)).catch(()=&amp;gt;process.exit(1))"]

&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["node", "server.js"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add a &lt;code&gt;.dockerignore&lt;/code&gt; so local junk and secrets never reach the build context:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;.git
.next
node_modules
npm-debug.log*
Dockerfile*
.dockerignore
.env*
!.env.example
coverage
README.md
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Build and run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker build &lt;span class="nt"&gt;-t&lt;/span&gt; my-next-app &lt;span class="nt"&gt;--build-arg&lt;/span&gt; &lt;span class="nv"&gt;NEXT_PUBLIC_SITE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://example.com &lt;span class="nb"&gt;.&lt;/span&gt;
docker run &lt;span class="nt"&gt;-p&lt;/span&gt; 3000:3000 &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"postgres://..."&lt;/span&gt; my-next-app
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnkoyj1kf5ods17jngxft.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnkoyj1kf5ods17jngxft.jpg" alt="Next.js standalone output in a multi-stage Docker build" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How do multi-stage Docker builds make Next.js images smaller?
&lt;/h2&gt;

&lt;p&gt;A multi-stage build lets the build stages carry the full &lt;code&gt;node_modules&lt;/code&gt;, TypeScript, ESLint and the compiler, while the final stage copies only the build output. Docker ships just the last stage, so none of the build tooling, source code or package cache ends up in the production image.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://docs.docker.com/build/building/multi-stage/" rel="noopener noreferrer"&gt;Docker multi-stage build docs&lt;/a&gt; describe this pattern: each &lt;code&gt;FROM&lt;/code&gt; starts a fresh stage, and &lt;code&gt;COPY --from=&amp;lt;stage&amp;gt;&lt;/code&gt; pulls selected files forward. For Next.js the three stages do one job each:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;deps&lt;/strong&gt; copies only &lt;code&gt;package.json&lt;/code&gt; and the lockfile, then runs &lt;code&gt;npm ci&lt;/code&gt;. Because source files are not copied yet, this layer stays cached until your dependencies change.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;builder&lt;/strong&gt; brings in &lt;code&gt;node_modules&lt;/code&gt; and your source and runs &lt;code&gt;next build&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;runner&lt;/strong&gt; starts from a clean Node image and copies the standalone server, static files and &lt;code&gt;public/&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  How much smaller is the image? (measured)
&lt;/h3&gt;

&lt;p&gt;I built a fresh Next.js 16.4.2 app from &lt;code&gt;create-next-app&lt;/code&gt; (App Router, TypeScript, Tailwind) three ways on the same machine and compared &lt;code&gt;docker images&lt;/code&gt; output:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Build&lt;/th&gt;
&lt;th&gt;Base image&lt;/th&gt;
&lt;th&gt;Image size&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Single stage: &lt;code&gt;COPY . .&lt;/code&gt;, &lt;code&gt;npm ci &amp;amp;&amp;amp; npm run build&lt;/code&gt;, &lt;code&gt;npm start&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;node:24&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1.97 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multi-stage + standalone (this guide)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;node:24-slim&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;287 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multi-stage + standalone&lt;/td&gt;
&lt;td&gt;&lt;code&gt;node:24-alpine&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;229 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The application files inside the standalone runner (&lt;code&gt;/app&lt;/code&gt;) took 59 MB; the rest is the Node.js base image. Your numbers will differ with your dependencies, but the order of magnitude is typical, because the standalone trace keeps only the packages the server actually imports.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does output: 'standalone' do in Next.js?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;output: 'standalone'&lt;/code&gt; tells &lt;code&gt;next build&lt;/code&gt; to create a &lt;code&gt;.next/standalone&lt;/code&gt; folder that contains a minimal &lt;code&gt;server.js&lt;/code&gt; and only the files from &lt;code&gt;node_modules&lt;/code&gt; that your routes need. You can run that folder with &lt;code&gt;node server.js&lt;/code&gt; and no &lt;code&gt;npm install&lt;/code&gt;, which is why it is the foundation of every small Next.js Docker image.&lt;/p&gt;

&lt;p&gt;From the &lt;a href="https://nextjs.org/docs/app/api-reference/config/next-config-js/output" rel="noopener noreferrer"&gt;Next.js output docs&lt;/a&gt;, three details matter for Docker:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It does not copy &lt;code&gt;public/&lt;/code&gt; or &lt;code&gt;.next/static&lt;/code&gt;.&lt;/strong&gt; Those are expected to go to a CDN, so the Dockerfile copies them next to &lt;code&gt;server.js&lt;/code&gt; manually. Forget this and your CSS, JS chunks and images return 404.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;PORT&lt;/code&gt; and &lt;code&gt;HOSTNAME&lt;/code&gt; control where the server listens.&lt;/strong&gt; Inside a container you want &lt;code&gt;HOSTNAME=0.0.0.0&lt;/code&gt;, otherwise the server may bind only to the container's own interface name and the published port will not answer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Monorepos need &lt;code&gt;outputFileTracingRoot&lt;/code&gt;.&lt;/strong&gt; If your app imports packages from outside its folder, set it to the repository root so tracing can include them. If a file is still missing, &lt;code&gt;outputFileTracingIncludes&lt;/code&gt; adds it explicitly.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Should you use next start or node server.js in Docker?
&lt;/h3&gt;

&lt;p&gt;Use &lt;code&gt;node server.js&lt;/code&gt; with standalone output. It runs the same Next.js server without needing the full &lt;code&gt;node_modules&lt;/code&gt; tree or the &lt;code&gt;next&lt;/code&gt; CLI, and it runs directly as PID 1 rather than through &lt;code&gt;npm&lt;/code&gt;. That means &lt;code&gt;docker stop&lt;/code&gt; sends SIGTERM straight to Node instead of to an npm wrapper process.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which Node.js base image should you use for Next.js?
&lt;/h2&gt;

&lt;p&gt;Use the current Node.js LTS line; as of October 2026 that is Node.js 24 ("Krypton"), per the &lt;a href="https://nodejs.org/en/about/previous-releases" rel="noopener noreferrer"&gt;Node.js release schedule&lt;/a&gt;. Pick &lt;code&gt;node:24-slim&lt;/code&gt; (Debian, glibc) as the safe default, or &lt;code&gt;node:24-alpine&lt;/code&gt; (musl) for a smaller image if your native dependencies support musl.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;Pros&lt;/th&gt;
&lt;th&gt;Watch out for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;node:24-slim&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;glibc, widest native-module compatibility; the official example's default&lt;/td&gt;
&lt;td&gt;Larger than Alpine&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;node:24-alpine&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Smallest official Node image&lt;/td&gt;
&lt;td&gt;musl libc; some native modules need musl builds or extra packages&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;node:24&lt;/code&gt; (full)&lt;/td&gt;
&lt;td&gt;Has build tools for compiling native modules&lt;/td&gt;
&lt;td&gt;Very large; use only in a build stage if needed&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Pin the version with an &lt;code&gt;ARG&lt;/code&gt; (as in the Dockerfile above) so upgrading Node is a one-line change, and rebuild regularly to pick up base image security patches.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you run a Next.js container as a non-root user?
&lt;/h2&gt;

&lt;p&gt;Switch to the non-root &lt;code&gt;node&lt;/code&gt; user that ships in every official Node.js image with &lt;code&gt;USER node&lt;/code&gt;, and give it ownership of the files it needs with &lt;code&gt;COPY --chown=node:node&lt;/code&gt;. Create &lt;code&gt;.next&lt;/code&gt; and &lt;code&gt;chown&lt;/code&gt; it first, because Next.js writes its prerender and image cache there at runtime.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://github.com/nodejs/docker-node/blob/main/docs/BestPractices.md" rel="noopener noreferrer"&gt;Node.js Docker best practices&lt;/a&gt; recommend exactly this: the official images already include a &lt;code&gt;node&lt;/code&gt; user (UID 1000), so there is no need to create your own. I confirmed it in the built container: &lt;code&gt;id&lt;/code&gt; returns &lt;code&gt;uid=1000(node) gid=1000(node)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Two permission mistakes cause most "EACCES" crashes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Copying files without &lt;code&gt;--chown&lt;/code&gt;, so they belong to root and the cache directory is read-only for &lt;code&gt;node&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Mounting a volume over &lt;code&gt;/app/.next/cache&lt;/code&gt; that is owned by root on the host. Fix the host directory's owner or skip the mount.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  How do you make sharp work in a Next.js Docker image?
&lt;/h2&gt;

&lt;p&gt;With Next.js 15 and later you usually do nothing: &lt;code&gt;next&lt;/code&gt; pulls in &lt;code&gt;sharp&lt;/code&gt; as a dependency, and standalone tracing copies it into the image along with its prebuilt libvips binaries for Linux. Run &lt;code&gt;npm ci&lt;/code&gt; inside the container (not on your Mac or Windows host) so the correct Linux binaries are installed.&lt;/p&gt;

&lt;p&gt;In my test build, the runner's &lt;code&gt;node_modules&lt;/code&gt; contained &lt;code&gt;sharp&lt;/code&gt; plus &lt;code&gt;@img/sharp-linux-x64&lt;/code&gt; and &lt;code&gt;@img/sharp-libvips-linux-x64&lt;/code&gt; without any extra configuration, and calling &lt;code&gt;require('sharp')&lt;/code&gt; inside the running container worked (sharp 0.35.5).&lt;/p&gt;

&lt;p&gt;If you do see the &lt;a href="https://nextjs.org/docs/messages/sharp-missing-in-production" rel="noopener noreferrer"&gt;sharp missing in production&lt;/a&gt; warning, check these in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Do not copy a host &lt;code&gt;node_modules&lt;/code&gt; into the image.&lt;/strong&gt; Keep &lt;code&gt;node_modules&lt;/code&gt; in &lt;code&gt;.dockerignore&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Match the platform.&lt;/strong&gt; Building on Apple Silicon for an x64 server? Use &lt;code&gt;docker build --platform linux/amd64&lt;/code&gt; so the right binaries are chosen.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Force the files into the trace.&lt;/strong&gt; Add &lt;code&gt;outputFileTracingIncludes: { '/*': ['node_modules/sharp/**/*'] }&lt;/code&gt; to &lt;code&gt;next.config&lt;/code&gt;, as the Next.js docs show for native assets.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Remember the default image optimizer runs on your server's CPU. If you serve lots of images, the &lt;a href="https://nextjs.org/docs/app/guides/self-hosting" rel="noopener noreferrer"&gt;self-hosting guide&lt;/a&gt; explains how to swap in a custom loader that points to an image CDN.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you handle environment variables in a Next.js Docker build?
&lt;/h2&gt;

&lt;p&gt;Treat them as two different things. Variables prefixed with &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; are inlined into the browser JavaScript at &lt;code&gt;next build&lt;/code&gt;, so they must be passed as build arguments. Everything else (database URLs, API keys, auth secrets) is read by the server at runtime, so pass it with &lt;code&gt;docker run -e&lt;/code&gt; or your platform's environment settings and never bake it into the image.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://nextjs.org/docs/app/guides/environment-variables" rel="noopener noreferrer"&gt;Next.js environment variables guide&lt;/a&gt; spells this out: once built, a &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; value is frozen into the bundle. Changing it later requires a rebuild.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Variable type&lt;/th&gt;
&lt;th&gt;Example&lt;/th&gt;
&lt;th&gt;Where to set it&lt;/th&gt;
&lt;th&gt;Rebuild needed to change?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Public (browser)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;NEXT_PUBLIC_SITE_URL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ARG&lt;/code&gt; + &lt;code&gt;ENV&lt;/code&gt; in builder stage, &lt;code&gt;--build-arg&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Server secret&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;DATABASE_URL&lt;/code&gt;, &lt;code&gt;AUTH_SECRET&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;docker run -e&lt;/code&gt;, Compose &lt;code&gt;environment&lt;/code&gt;, Coolify env vars&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Server config read at request time&lt;/td&gt;
&lt;td&gt;&lt;code&gt;REVALIDATE_TOKEN&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Runtime env&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  What if the build itself needs a secret?
&lt;/h3&gt;

&lt;p&gt;Some builds need a secret, for example to fetch data during prerendering or to install a private package. Do not use &lt;code&gt;ARG&lt;/code&gt; for it, because build arguments can show up in image history. Use a BuildKit secret mount, which exposes the value only for one &lt;code&gt;RUN&lt;/code&gt; step:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;RUN &lt;/span&gt;&lt;span class="nt"&gt;--mount&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;secret,id&lt;span class="o"&gt;=&lt;/span&gt;npm_token,env&lt;span class="o"&gt;=&lt;/span&gt;NPM_TOKEN npm ci
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker build &lt;span class="nt"&gt;--secret&lt;/span&gt; &lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;npm_token,env&lt;span class="o"&gt;=&lt;/span&gt;NPM_TOKEN &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A simpler rule that avoids most of this: keep pages that need secrets dynamic, so they read runtime env on the request instead of at build time.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you add a health check to a Next.js container?
&lt;/h2&gt;

&lt;p&gt;Add a tiny route handler that returns 200, then point a Dockerfile &lt;code&gt;HEALTHCHECK&lt;/code&gt; at it using Node's built-in &lt;code&gt;fetch&lt;/code&gt;, because slim and Alpine images do not include &lt;code&gt;curl&lt;/code&gt; by default. Docker marks the container healthy or unhealthy, and orchestrators like Coolify, Compose and Swarm use that status to decide when traffic can switch to a new deploy.&lt;/p&gt;

&lt;p&gt;The health route (App Router):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/app/api/health/route.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;connection&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;next/server&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;GET&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// opt out of prerendering: answer on every request&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ok&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;uptime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uptime&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;await connection()&lt;/code&gt; makes the handler run on every request, so a stuck server cannot hide behind a prerendered response. The &lt;a href="https://docs.docker.com/reference/dockerfile/#healthcheck" rel="noopener noreferrer"&gt;Dockerfile HEALTHCHECK reference&lt;/a&gt; explains the options used above:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;--start-period=20s&lt;/code&gt; gives the server time to boot before failures count.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--interval=30s&lt;/code&gt; and &lt;code&gt;--timeout=5s&lt;/code&gt; set how often and how long each probe runs.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--retries=3&lt;/code&gt; means three failures in a row flip the status to unhealthy.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Running the test image, &lt;code&gt;docker inspect&lt;/code&gt; reported &lt;code&gt;healthy&lt;/code&gt; and &lt;code&gt;/api/health&lt;/code&gt; returned &lt;code&gt;{"status":"ok",...}&lt;/code&gt;. Keep this endpoint cheap. If you also want a readiness check that pings the database, make it a separate route so a slow database does not get the whole container restarted.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnqunrfgdz6prji9q28f9.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnqunrfgdz6prji9q28f9.jpg" alt="Where a production Next.js container gets its config, health checks and assets" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you speed up Next.js Docker builds?
&lt;/h2&gt;

&lt;p&gt;Order layers from least to most frequently changed, and use BuildKit cache mounts. Copy the lockfile and run &lt;code&gt;npm ci&lt;/code&gt; before copying source, so dependency installs are reused on every code-only change. The &lt;code&gt;--mount=type=cache,target=/root/.npm&lt;/code&gt; flag keeps npm's download cache between builds without putting it into the image.&lt;/p&gt;

&lt;p&gt;More options, from Docker's &lt;a href="https://docs.docker.com/build/cache/optimize/" rel="noopener noreferrer"&gt;build cache optimization guide&lt;/a&gt; and the official example:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Keep &lt;code&gt;.dockerignore&lt;/code&gt; tight&lt;/strong&gt; so a changed README or &lt;code&gt;.git&lt;/code&gt; folder does not invalidate &lt;code&gt;COPY . .&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache &lt;code&gt;.next/cache&lt;/code&gt;&lt;/strong&gt; with &lt;code&gt;RUN --mount=type=cache,target=/app/.next/cache npm run build&lt;/code&gt; to reuse compiler output. The official example notes the trade-off: that cache is then not in the final image, so fetch responses cached during the build are not available at startup.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use the lockfile your project already has.&lt;/strong&gt; The official example detects npm, Yarn or pnpm; if you use pnpm, swap the install line for &lt;code&gt;corepack enable pnpm &amp;amp;&amp;amp; pnpm install --frozen-lockfile&lt;/code&gt; and copy &lt;code&gt;pnpm-lock.yaml&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  How do you deploy this Next.js Dockerfile to Coolify or any host?
&lt;/h2&gt;

&lt;p&gt;Because the image is self-contained, any Docker host can run it: a plain VPS with &lt;code&gt;docker run&lt;/code&gt;, Docker Compose, Kubernetes, Fly.io, Railway or Coolify. Expose port 3000, set runtime environment variables on the platform, pass &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; values as build arguments, and put a reverse proxy with HTTPS in front.&lt;/p&gt;

&lt;p&gt;In Coolify specifically:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Choose the &lt;strong&gt;Dockerfile&lt;/strong&gt; build pack and keep the Dockerfile at the repository root.&lt;/li&gt;
&lt;li&gt;Set &lt;strong&gt;Ports Exposes&lt;/strong&gt; to &lt;code&gt;3000&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Add runtime variables (like &lt;code&gt;DATABASE_URL&lt;/code&gt;) as normal environment variables, and make sure &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; ones are available at build time so they reach the &lt;code&gt;ARG&lt;/code&gt; in the builder stage.&lt;/li&gt;
&lt;li&gt;Coolify can use the container's health check during deploys, so the &lt;code&gt;HEALTHCHECK&lt;/code&gt; above helps it avoid routing traffic to a container that never became healthy.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I walk through the full Coolify setup, including domains and PostgreSQL, in the &lt;a href="https://www.fabwaseem.com/blog/self-host-nextjs-with-coolify" rel="noopener noreferrer"&gt;self-hosting guide&lt;/a&gt;. If you are still deciding between platforms, &lt;a href="https://www.fabwaseem.com/blog/coolify-vs-vercel" rel="noopener noreferrer"&gt;Coolify vs Vercel for Next.js&lt;/a&gt; compares cost and trade-offs. This same pattern runs this site and projects like &lt;a href="https://www.fabwaseem.com/projects/foliora" rel="noopener noreferrer"&gt;Foliora&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Running more than one replica?
&lt;/h3&gt;

&lt;p&gt;Each container has its own &lt;code&gt;.next/cache&lt;/code&gt;, so ISR and image caches are not shared between replicas by default. The &lt;a href="https://nextjs.org/docs/app/guides/self-hosting" rel="noopener noreferrer"&gt;Next.js self-hosting guide&lt;/a&gt; covers configuring a shared cache handler (for example, Redis-backed) when you scale horizontally. A single container per app does not need this.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common Next.js Docker errors and fixes
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;th&gt;Likely cause&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CSS/JS 404s, unstyled page&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;.next/static&lt;/code&gt; not copied&lt;/td&gt;
&lt;td&gt;&lt;code&gt;COPY --from=builder /app/.next/static ./.next/static&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Images in &lt;code&gt;public/&lt;/code&gt; return 404&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;public/&lt;/code&gt; not copied&lt;/td&gt;
&lt;td&gt;&lt;code&gt;COPY --from=builder /app/public ./public&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Container runs but port does not answer&lt;/td&gt;
&lt;td&gt;Server bound to the wrong interface&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ENV HOSTNAME=0.0.0.0&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;EACCES&lt;/code&gt; writing &lt;code&gt;.next/cache&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Files owned by root&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;mkdir .next &amp;amp;&amp;amp; chown node:node .next&lt;/code&gt;, &lt;code&gt;COPY --chown&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; value is empty or stale&lt;/td&gt;
&lt;td&gt;Set only at runtime&lt;/td&gt;
&lt;td&gt;Pass as &lt;code&gt;--build-arg&lt;/code&gt; and rebuild&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Cannot find module&lt;/code&gt; at runtime&lt;/td&gt;
&lt;td&gt;File not traced (monorepo or dynamic &lt;code&gt;require&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;outputFileTracingRoot&lt;/code&gt; / &lt;code&gt;outputFileTracingIncludes&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sharp warning in logs&lt;/td&gt;
&lt;td&gt;Host-built &lt;code&gt;node_modules&lt;/code&gt; or wrong platform&lt;/td&gt;
&lt;td&gt;Ignore host &lt;code&gt;node_modules&lt;/code&gt;, build with &lt;code&gt;--platform&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Related:&lt;/strong&gt; the builder stage runs your type check and any Prisma commands, so two toolchain changes affect it: &lt;a href="https://www.fabwaseem.com/blog/typescript-7" rel="noopener noreferrer"&gt;TypeScript 7&lt;/a&gt; makes &lt;code&gt;next build&lt;/code&gt; type checking faster, and &lt;a href="https://www.fabwaseem.com/blog/prisma-8" rel="noopener noreferrer"&gt;Prisma 8&lt;/a&gt; changed what an unpinned &lt;code&gt;npx prisma&lt;/code&gt; installs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Related:&lt;/strong&gt; thinking about Bun in your Docker image? In &lt;a href="https://www.fabwaseem.com/blog/bun-vs-node" rel="noopener noreferrer"&gt;Bun vs Node.js in 2026&lt;/a&gt; a Next.js 16.4 app installed in 1.15 s with bun vs 15.4 s with npm, and &lt;code&gt;next build&lt;/code&gt; worked on Bun 1.4.3.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Enable &lt;code&gt;output: 'standalone'&lt;/code&gt; and use a three-stage Dockerfile; in my measured test that cut a fresh Next.js 16 image from 1.97 GB to 287 MB on Debian slim.&lt;/li&gt;
&lt;li&gt;Standalone output does not include &lt;code&gt;public/&lt;/code&gt; or &lt;code&gt;.next/static&lt;/code&gt;; copy both into the runner yourself.&lt;/li&gt;
&lt;li&gt;Run as the built-in &lt;code&gt;node&lt;/code&gt; user and give it ownership of &lt;code&gt;.next&lt;/code&gt;, so the runtime cache can be written.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; variables are build-time and baked into the bundle; secrets belong in runtime env or BuildKit secret mounts, never &lt;code&gt;ARG&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Add a cheap &lt;code&gt;/api/health&lt;/code&gt; route and a Node &lt;code&gt;fetch&lt;/code&gt;-based &lt;code&gt;HEALTHCHECK&lt;/code&gt; so your platform can roll out new containers safely.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Do I need output: 'standalone' to run Next.js in Docker?
&lt;/h3&gt;

&lt;p&gt;No, but it is strongly recommended. Without it you must ship the full node_modules and run next start. With it, next build produces a minimal server.js plus only the traced dependencies, so the runtime image is far smaller and needs no npm install.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why are my CSS and JS files returning 404 in the Next.js Docker container?
&lt;/h3&gt;

&lt;p&gt;Standalone output does not include the public folder or .next/static. Copy both into the runner stage: .next/static to ./.next/static and public to ./public, next to server.js. The standalone server then serves them automatically.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I use node:24-alpine or node:24-slim for Next.js?
&lt;/h3&gt;

&lt;p&gt;Use node:24-slim as the safe default because it uses glibc and works with almost every native module, including sharp's prebuilt binaries. Alpine is smaller but uses musl, which some native dependencies do not support. In my test the Alpine image was 229 MB vs 287 MB for slim.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I pass environment variables to a Next.js Docker image?
&lt;/h3&gt;

&lt;p&gt;Pass NEXT_PUBLIC_ variables as build arguments, because Next.js inlines them into the browser bundle during next build. Pass server-only values like DATABASE_URL at runtime with docker run -e or your platform's settings. Never put secrets in ARG; use a BuildKit secret mount if the build needs one.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need to install sharp manually in a Next.js Docker image?
&lt;/h3&gt;

&lt;p&gt;Usually not on Next.js 15 and later. Next.js depends on sharp and standalone tracing copies it, with its prebuilt Linux libvips binaries, into the image. Install dependencies inside the container and keep host node_modules out of the build context so the correct platform binaries are used.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I add a Docker health check without curl?
&lt;/h3&gt;

&lt;p&gt;Slim and Alpine Node images do not include curl, so use Node's built-in fetch in the HEALTHCHECK command to call a lightweight /api/health route and exit 0 on a 2xx response or 1 otherwise. Add a start period so the server has time to boot.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.fabwaseem.com/blog/nextjs-dockerfile" rel="noopener noreferrer"&gt;fabwaseem.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>docker</category>
      <category>devops</category>
      <category>webdev</category>
    </item>
    <item>
      <title>AGENTS.md vs CLAUDE.md vs Cursor Rules: A Next.js Guide with a Free Rules Pack</title>
      <dc:creator>Waseem Anjum</dc:creator>
      <pubDate>Mon, 26 Oct 2026 22:03:30 +0000</pubDate>
      <link>https://dev.to/fabwaseem/agentsmd-vs-claudemd-vs-cursor-rules-a-nextjs-guide-with-a-free-rules-pack-310c</link>
      <guid>https://dev.to/fabwaseem/agentsmd-vs-claudemd-vs-cursor-rules-a-nextjs-guide-with-a-free-rules-pack-310c</guid>
      <description>&lt;p&gt;&lt;em&gt;Cross-posted from my blog. The original, with any updates, lives on &lt;a href="https://www.fabwaseem.com/blog/agents-md-vs-claude-md" rel="noopener noreferrer"&gt;fabwaseem.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AGENTS.md vs CLAUDE.md&lt;/strong&gt; comes down to scope: &lt;code&gt;AGENTS.md&lt;/code&gt; is an open, tool-neutral Markdown file that many coding agents read, while &lt;code&gt;CLAUDE.md&lt;/code&gt; is Claude Code's own project memory file. Cursor rules (&lt;code&gt;.cursor/rules/*.mdc&lt;/code&gt;) and GitHub Copilot instructions (&lt;code&gt;.github/copilot-instructions.md&lt;/code&gt;) are tool-specific formats that add scoping by file pattern. Most Next.js teams need more than one of them.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Free and open source:&lt;/strong&gt; I packaged production rules for Next.js, TypeScript, React, Node.js, NestJS, Prisma, Drizzle, Tailwind CSS and shadcn/ui into one repo that renders every format from a single source. Get it here: &lt;strong&gt;&lt;a href="https://github.com/fabwaseem/agent-rules" rel="noopener noreferrer"&gt;github.com/fabwaseem/agent-rules&lt;/a&gt;&lt;/strong&gt; (MIT).&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This guide explains what each file does, which agent reads which file, how they interact (Claude Code has specific rules for when it reads &lt;code&gt;AGENTS.md&lt;/code&gt;), and gives you real rule snippets you can copy. Everything below was checked against the official docs linked in each section.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is AGENTS.md?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;AGENTS.md is a plain Markdown file at the root of a repository that tells AI coding agents how to work in that codebase:&lt;/strong&gt; build and test commands, conventions, and things to avoid. It has no required fields or frontmatter. Agents read the nearest &lt;code&gt;AGENTS.md&lt;/code&gt; in the directory tree, so a nested file in a subproject takes precedence over the root one.&lt;/p&gt;

&lt;p&gt;The format is documented at &lt;a href="https://agents.md/" rel="noopener noreferrer"&gt;agents.md&lt;/a&gt;, which describes it as "a simple, open format for guiding coding agents" used by more than 60,000 open-source projects. Think of it as a README for agents: the README is for humans, &lt;code&gt;AGENTS.md&lt;/code&gt; is for the tool that edits your code.&lt;/p&gt;

&lt;p&gt;A minimal example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gh"&gt;# AGENTS.md&lt;/span&gt;

&lt;span class="gu"&gt;## Workspace&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Package manager: &lt;span class="sb"&gt;`pnpm`&lt;/span&gt;. Do not add a second lockfile.
&lt;span class="p"&gt;-&lt;/span&gt; Run &lt;span class="sb"&gt;`pnpm test`&lt;/span&gt; and &lt;span class="sb"&gt;`pnpm typecheck`&lt;/span&gt; before you finish.
&lt;span class="p"&gt;-&lt;/span&gt; Do not hand-edit generated output (&lt;span class="sb"&gt;`dist/`&lt;/span&gt;, &lt;span class="sb"&gt;`.next/`&lt;/span&gt;, generated database clients).
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What is CLAUDE.md?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;CLAUDE.md is the project instructions file for Claude Code.&lt;/strong&gt; Claude reads it at the start of every session from &lt;code&gt;./CLAUDE.md&lt;/code&gt; or &lt;code&gt;./.claude/CLAUDE.md&lt;/code&gt;, plus your personal &lt;code&gt;~/.claude/CLAUDE.md&lt;/code&gt;. It is plain Markdown, can import other files with &lt;code&gt;@path/to/file&lt;/code&gt;, and Anthropic recommends keeping each file under 200 lines because longer files cost context and reduce adherence.&lt;/p&gt;

&lt;p&gt;Those details come straight from the &lt;a href="https://code.claude.com/docs/en/memory" rel="noopener noreferrer"&gt;Claude Code memory docs&lt;/a&gt;. Two things matter most in practice:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Imports.&lt;/strong&gt; A line like &lt;code&gt;@AGENTS.md&lt;/code&gt; in &lt;code&gt;CLAUDE.md&lt;/code&gt; pulls that file into context at launch, up to four hops deep. This is how you share one source of truth between Claude and every other agent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Instructions are context, not enforcement.&lt;/strong&gt; The docs say so directly: to block an action no matter what, use a hook, not a sentence in &lt;code&gt;CLAUDE.md&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Does Claude Code read AGENTS.md?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Yes, but only in one situation by default.&lt;/strong&gt; Claude Code reads &lt;code&gt;AGENTS.md&lt;/code&gt; as project instructions when there is no &lt;code&gt;CLAUDE.md&lt;/code&gt;, &lt;code&gt;.claude/CLAUDE.md&lt;/code&gt; or &lt;code&gt;CLAUDE.local.md&lt;/code&gt; in your working directory or above it. If a &lt;code&gt;CLAUDE.md&lt;/code&gt; exists, Claude reads only the &lt;code&gt;CLAUDE.md&lt;/code&gt; files, unless that file imports &lt;code&gt;AGENTS.md&lt;/code&gt; or you change the Project instructions setting.&lt;/p&gt;

&lt;p&gt;Here is the behavior from the official docs, summarized:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Your repository has&lt;/th&gt;
&lt;th&gt;Claude Code reads&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;AGENTS.md&lt;/code&gt; only&lt;/td&gt;
&lt;td&gt;&lt;code&gt;AGENTS.md&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;AGENTS.md&lt;/code&gt; and &lt;code&gt;CLAUDE.md&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;CLAUDE.md&lt;/code&gt; only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;CLAUDE.md&lt;/code&gt; that contains &lt;code&gt;@AGENTS.md&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;CLAUDE.md&lt;/code&gt; with &lt;code&gt;AGENTS.md&lt;/code&gt; included&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Reading &lt;code&gt;AGENTS.md&lt;/code&gt; directly also needs a recent Claude Code version (v2.1.277 or later per the docs). The safest setup for a mixed team is a short &lt;code&gt;CLAUDE.md&lt;/code&gt; that imports &lt;code&gt;AGENTS.md&lt;/code&gt; and adds only Claude-specific notes. That is exactly what the &lt;a href="https://github.com/fabwaseem/agent-rules" rel="noopener noreferrer"&gt;agent-rules&lt;/a&gt; installer writes.&lt;/p&gt;

&lt;h2&gt;
  
  
  AGENTS.md vs CLAUDE.md vs Cursor rules vs Copilot instructions: what is the difference?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;They differ in who reads them and how they are scoped.&lt;/strong&gt; &lt;code&gt;AGENTS.md&lt;/code&gt; is shared by many agents and scoped by directory. &lt;code&gt;CLAUDE.md&lt;/code&gt; is Claude Code only. Cursor &lt;code&gt;.mdc&lt;/code&gt; rules can attach to specific file globs or load when the agent judges them relevant. Copilot path instructions use an &lt;code&gt;applyTo&lt;/code&gt; glob. Use &lt;code&gt;AGENTS.md&lt;/code&gt; for shared rules and the others for targeted ones.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F231fc9aq6hv1zpfta3vp.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F231fc9aq6hv1zpfta3vp.jpg" alt="Diagram: one source rule rendered into AGENTS.md, CLAUDE.md, Cursor rules, Copilot instructions, Claude skills and Windsurf/Devin rules" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;AGENTS.md&lt;/th&gt;
&lt;th&gt;CLAUDE.md&lt;/th&gt;
&lt;th&gt;Cursor &lt;code&gt;.mdc&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;Copilot instructions&lt;/th&gt;
&lt;th&gt;Claude Code skill&lt;/th&gt;
&lt;th&gt;Windsurf / Devin&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Path&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;AGENTS.md&lt;/code&gt; (nested allowed)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;CLAUDE.md&lt;/code&gt;, &lt;code&gt;.claude/CLAUDE.md&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.cursor/rules/*.mdc&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;.github/copilot-instructions.md&lt;/code&gt;, &lt;code&gt;.github/instructions/*.instructions.md&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.claude/skills/&amp;lt;name&amp;gt;/SKILL.md&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;.devin/rules/*.md&lt;/code&gt;, &lt;code&gt;.windsurf/rules/*.md&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Frontmatter&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;description&lt;/code&gt;, &lt;code&gt;globs&lt;/code&gt;, &lt;code&gt;alwaysApply&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Path files: &lt;code&gt;applyTo&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;name&lt;/code&gt;, &lt;code&gt;description&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;trigger&lt;/code&gt; plus &lt;code&gt;globs&lt;/code&gt; or &lt;code&gt;description&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scope&lt;/td&gt;
&lt;td&gt;Nearest file in the tree&lt;/td&gt;
&lt;td&gt;Claude Code sessions&lt;/td&gt;
&lt;td&gt;Always, by glob, agent-selected or &lt;code&gt;@&lt;/code&gt; mention&lt;/td&gt;
&lt;td&gt;Repo-wide or matching &lt;code&gt;applyTo&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Loaded on demand&lt;/td&gt;
&lt;td&gt;Always, glob, model decision or manual&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best for&lt;/td&gt;
&lt;td&gt;Rules every agent shares&lt;/td&gt;
&lt;td&gt;Claude-specific additions&lt;/td&gt;
&lt;td&gt;Rules tied to certain files&lt;/td&gt;
&lt;td&gt;Copilot Chat, review and coding agent&lt;/td&gt;
&lt;td&gt;Multi-step procedures&lt;/td&gt;
&lt;td&gt;Cascade / Devin workspace rules&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Sources: &lt;a href="https://agents.md/" rel="noopener noreferrer"&gt;agents.md&lt;/a&gt;, &lt;a href="https://cursor.com/docs/rules" rel="noopener noreferrer"&gt;Cursor Rules docs&lt;/a&gt;, &lt;a href="https://code.claude.com/docs/en/memory" rel="noopener noreferrer"&gt;Claude Code memory&lt;/a&gt;, &lt;a href="https://code.claude.com/docs/en/skills" rel="noopener noreferrer"&gt;Claude Code skills&lt;/a&gt;, &lt;a href="https://docs.github.com/en/copilot/how-tos/configure-custom-instructions/add-repository-instructions" rel="noopener noreferrer"&gt;GitHub Copilot repository instructions&lt;/a&gt; and &lt;a href="https://docs.devin.ai/desktop/cascade/memories" rel="noopener noreferrer"&gt;Windsurf / Devin rules&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do Cursor rules work in a Next.js project?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Cursor reads &lt;code&gt;.mdc&lt;/code&gt; files from &lt;code&gt;.cursor/rules/&lt;/code&gt;, and the frontmatter decides when each rule loads.&lt;/strong&gt; &lt;code&gt;alwaysApply: true&lt;/code&gt; includes it every session. &lt;code&gt;alwaysApply: false&lt;/code&gt; with &lt;code&gt;globs&lt;/code&gt; attaches it when a matching file is in context. A &lt;code&gt;description&lt;/code&gt; with no globs lets the agent pull it in when relevant. With neither, it loads only when you &lt;code&gt;@&lt;/code&gt; mention it.&lt;/p&gt;

&lt;p&gt;One gotcha: Cursor ignores a plain &lt;code&gt;.md&lt;/code&gt; file inside &lt;code&gt;.cursor/rules/&lt;/code&gt;. The extension must be &lt;code&gt;.mdc&lt;/code&gt;. Here is the real App Router rule from the repo, trimmed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Next.js&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;App&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Router&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;structure,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Server&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Components,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;routing&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;files,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;and&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;proxy.ts."&lt;/span&gt;
&lt;span class="na"&gt;globs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;app/**/*.ts,app/**/*.tsx,src/app/**/*.ts,src/app/**/*.tsx,next.config.*,proxy.ts,middleware.ts"&lt;/span&gt;
&lt;span class="na"&gt;alwaysApply&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;

&lt;span class="gh"&gt;# Next.js App Router&lt;/span&gt;
&lt;span class="p"&gt;
-&lt;/span&gt; New UI goes in the App Router. Do not add a &lt;span class="sb"&gt;`pages/`&lt;/span&gt; route.
&lt;span class="p"&gt;-&lt;/span&gt; Components are Server Components by default. Add &lt;span class="sb"&gt;`"use client"`&lt;/span&gt; only on the leaf
  that needs state, effects, or browser APIs.
&lt;span class="p"&gt;-&lt;/span&gt; Read &lt;span class="sb"&gt;`params`&lt;/span&gt; and &lt;span class="sb"&gt;`searchParams`&lt;/span&gt; as promises and &lt;span class="sb"&gt;`await`&lt;/span&gt; them.
&lt;span class="p"&gt;-&lt;/span&gt; Set document metadata with the Metadata API. Do not add &lt;span class="sb"&gt;`next/head`&lt;/span&gt;.
&lt;span class="p"&gt;-&lt;/span&gt; Import &lt;span class="sb"&gt;`server-only`&lt;/span&gt; in modules that read secrets or talk to the database.
&lt;span class="p"&gt;-&lt;/span&gt; On Next.js 16, request interception lives in &lt;span class="sb"&gt;`proxy.ts`&lt;/span&gt; and exports &lt;span class="sb"&gt;`proxy`&lt;/span&gt;.
&lt;span class="p"&gt;-&lt;/span&gt; Do not flip &lt;span class="sb"&gt;`cacheComponents`&lt;/span&gt; on as a drive-by.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because it has globs, this rule costs nothing when you are editing a Prisma schema or a README. That is the main advantage of Cursor rules over one giant &lt;code&gt;AGENTS.md&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What should go in AGENTS.md for a Next.js app?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Put the things every agent must know in every session:&lt;/strong&gt; the package manager and scripts, how to run tests and typecheck, which folders are generated, and short, specific conventions. Keep stack details that only matter for some files in scoped rules. Short and concrete beats long and vague, because agents follow specific instructions more reliably.&lt;/p&gt;

&lt;p&gt;A good checklist for a Next.js &lt;code&gt;AGENTS.md&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Commands:&lt;/strong&gt; install, dev, test, typecheck, lint, build.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Package manager:&lt;/strong&gt; one, named explicitly, with "do not add a second lockfile".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Generated paths:&lt;/strong&gt; &lt;code&gt;.next/&lt;/code&gt;, &lt;code&gt;dist/&lt;/code&gt;, coverage, Prisma client.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Conventions:&lt;/strong&gt; Server Components by default, Zod validation at boundaries, server actions that authorize inside the action.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Git:&lt;/strong&gt; commit format and what not to do (no force-push on shared branches).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Definition of done:&lt;/strong&gt; tests pass, types check, no new warnings.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Avoid generic advice like "write clean code". It costs tokens and changes nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do GitHub Copilot instructions differ?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Copilot uses a repo-wide &lt;code&gt;.github/copilot-instructions.md&lt;/code&gt; plus optional path-specific files in &lt;code&gt;.github/instructions/&lt;/code&gt; that end in &lt;code&gt;.instructions.md&lt;/code&gt; and declare an &lt;code&gt;applyTo&lt;/code&gt; glob.&lt;/strong&gt; Copilot Chat, code review and the Copilot coding agent read them. Copilot also reads &lt;code&gt;AGENTS.md&lt;/code&gt; files, with the nearest one taking precedence, but &lt;code&gt;applyTo&lt;/code&gt; files are how you scope rules to certain paths.&lt;/p&gt;

&lt;p&gt;A real path-specific file from the repo's NestJS example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;applyTo&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;**/*.controller.ts,**/*.service.ts,**/*.module.ts,**/*.guard.ts,**/*.dto.ts"&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;

&lt;span class="gh"&gt;# NestJS&lt;/span&gt;
&lt;span class="p"&gt;
-&lt;/span&gt; A feature is a module: &lt;span class="sb"&gt;`*.module.ts`&lt;/span&gt;, a controller, a service, and DTOs.
&lt;span class="p"&gt;-&lt;/span&gt; Controllers translate HTTP. Services hold business rules. A controller does
  not import Prisma or Drizzle directly.
&lt;span class="p"&gt;-&lt;/span&gt; Do not read &lt;span class="sb"&gt;`process.env`&lt;/span&gt; inside a service. Inject &lt;span class="sb"&gt;`ConfigService`&lt;/span&gt;.
&lt;span class="p"&gt;-&lt;/span&gt; Authentication is a guard. Resource authorization happens in the service.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  When should you use a Claude Code skill instead of a rule?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Use a rule for a constraint and a skill for a procedure.&lt;/strong&gt; A rule says "never do X in these files". A skill is a step-by-step workflow, like making a commit or adding a server action, that Claude loads only when its &lt;code&gt;description&lt;/code&gt; matches the task or when you type &lt;code&gt;/name&lt;/code&gt;. Skills live in &lt;code&gt;.claude/skills/&amp;lt;name&amp;gt;/SKILL.md&lt;/code&gt; with &lt;code&gt;name&lt;/code&gt; and &lt;code&gt;description&lt;/code&gt; frontmatter.&lt;/p&gt;

&lt;p&gt;Per the &lt;a href="https://code.claude.com/docs/en/skills" rel="noopener noreferrer"&gt;skills docs&lt;/a&gt;, &lt;code&gt;name&lt;/code&gt; is lowercase letters, numbers and hyphens. Here is the commit skill from the pack:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;git-commit&lt;/span&gt;
&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Use when creating a git commit or pull request and the change should follow this repository's commit conventions.&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;

&lt;span class="gh"&gt;# Commit a change&lt;/span&gt;
&lt;span class="p"&gt;
1.&lt;/span&gt; Inspect &lt;span class="sb"&gt;`git status`&lt;/span&gt; and &lt;span class="sb"&gt;`git diff`&lt;/span&gt;. Stage only the files that belong to this change.
&lt;span class="p"&gt;2.&lt;/span&gt; Run typecheck and the tests that cover the change. Do not commit a known failure.
&lt;span class="p"&gt;3.&lt;/span&gt; Write a Conventional Commit subject: &lt;span class="sb"&gt;`type(scope): imperative summary`&lt;/span&gt;.
&lt;span class="p"&gt;4.&lt;/span&gt; Do not add &lt;span class="sb"&gt;`Co-authored-by`&lt;/span&gt; or other trailers unless the user asked.
&lt;span class="p"&gt;5.&lt;/span&gt; Do not amend or force-push a commit that is already on a remote.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What is agent-rules and what does it include?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/fabwaseem/agent-rules" rel="noopener noreferrer"&gt;agent-rules&lt;/a&gt; is an MIT-licensed pack of production rules for AI coding agents, written once as &lt;code&gt;.mdc&lt;/code&gt; source files and rendered into AGENTS.md, CLAUDE.md, Cursor rules, Copilot instructions, Claude Code skills and Windsurf/Devin rules.&lt;/strong&gt; You pick the stacks you use, and the installer writes only those, so the formats never drift apart.&lt;/p&gt;

&lt;p&gt;Stacks covered:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stack&lt;/th&gt;
&lt;th&gt;What the rules argue for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Core&lt;/td&gt;
&lt;td&gt;Strict TypeScript, no &lt;code&gt;any&lt;/code&gt; or &lt;code&gt;enum&lt;/code&gt;, Zod at runtime, Conventional Commits, WCAG 2.2 AA, Vitest&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Next.js&lt;/td&gt;
&lt;td&gt;App Router only, Server Components first, &lt;code&gt;params&lt;/code&gt; as promises, &lt;code&gt;proxy.ts&lt;/code&gt; on Next.js 16, server actions that validate and authorize&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;React&lt;/td&gt;
&lt;td&gt;Composition, derived state, &lt;code&gt;ref&lt;/code&gt; as a prop on React 19&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Node.js&lt;/td&gt;
&lt;td&gt;Validate env once at startup, handle &lt;code&gt;SIGTERM&lt;/code&gt;, timeouts on outbound calls&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NestJS&lt;/td&gt;
&lt;td&gt;Feature modules, thin controllers, whitelist validation, guards for auth&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Prisma&lt;/td&gt;
&lt;td&gt;Follow the installed major (Prisma 7 driver adapters), new migration per change, one client per process&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Drizzle&lt;/td&gt;
&lt;td&gt;Schema in TypeScript, &lt;code&gt;drizzle-kit generate&lt;/code&gt; then &lt;code&gt;migrate&lt;/code&gt;, bound parameters only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tailwind CSS&lt;/td&gt;
&lt;td&gt;v4 &lt;code&gt;@import "tailwindcss"&lt;/code&gt; and &lt;code&gt;@theme&lt;/code&gt; tokens, &lt;code&gt;cn()&lt;/code&gt; for conditional classes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;shadcn/ui&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;components/ui&lt;/code&gt; is source you own, semantic colors, compose what the CLI added&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;It also ships 12 Claude Code skills (commit, add tests, security review, accessibility pass, Next.js server action, Next.js cache, React component, Node service, Prisma change, Drizzle change, NestJS feature and shadcn compose) and two committed examples: a Next.js SaaS setup and a NestJS API.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you install the rules into your project?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Clone the repo, build the installer and point it at your project.&lt;/strong&gt; It detects your stacks from &lt;code&gt;package.json&lt;/code&gt; and &lt;code&gt;components.json&lt;/code&gt;, asks you to confirm, then writes each format. Re-running updates only its own marked blocks in &lt;code&gt;AGENTS.md&lt;/code&gt;, &lt;code&gt;CLAUDE.md&lt;/code&gt; and &lt;code&gt;copilot-instructions.md&lt;/code&gt;, so text you wrote outside them stays.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/fabwaseem/agent-rules.git
&lt;span class="nb"&gt;cd &lt;/span&gt;agent-rules &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm run build

&lt;span class="c"&gt;# preview what would be written&lt;/span&gt;
node dist/cli.js &lt;span class="nt"&gt;--target&lt;/span&gt; ../my-next-app &lt;span class="nt"&gt;--dry-run&lt;/span&gt;

&lt;span class="c"&gt;# install chosen stacks and formats&lt;/span&gt;
node dist/cli.js &lt;span class="nt"&gt;--target&lt;/span&gt; ../my-next-app &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--stacks&lt;/span&gt; core,nextjs,react,prisma,tailwind,shadcn &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--agents&lt;/span&gt; agents,claude,cursor,copilot
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Prefer no tooling? Copy files by hand from &lt;a href="https://github.com/fabwaseem/agent-rules/tree/main/examples/nextjs-saas" rel="noopener noreferrer"&gt;&lt;code&gt;examples/nextjs-saas&lt;/code&gt;&lt;/a&gt;: take &lt;code&gt;AGENTS.md&lt;/code&gt;, &lt;code&gt;CLAUDE.md&lt;/code&gt;, &lt;code&gt;.cursor/rules/&lt;/code&gt;, &lt;code&gt;.github/&lt;/code&gt; and &lt;code&gt;.claude/skills/&lt;/code&gt; and drop them into your repo. An &lt;code&gt;npx&lt;/code&gt; one-liner is coming soon once the package is published to npm.&lt;/p&gt;

&lt;p&gt;The installer needs Node.js 20.19 or later. It also includes a &lt;code&gt;validate&lt;/code&gt; command that checks Cursor frontmatter keys, Copilot &lt;code&gt;applyTo&lt;/code&gt;, skill names and Windsurf triggers, including the 12,000-character Windsurf workspace rule limit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should you keep one file or several?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Keep one shared source and generate the rest.&lt;/strong&gt; Hand-maintaining five formats means they drift: someone updates Cursor rules and forgets &lt;code&gt;AGENTS.md&lt;/code&gt;. A single &lt;code&gt;AGENTS.md&lt;/code&gt; plus a &lt;code&gt;CLAUDE.md&lt;/code&gt; that imports it works for small repos. Larger repos benefit from glob-scoped Cursor and Copilot files so each conversation only carries relevant rules.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Repo size&lt;/th&gt;
&lt;th&gt;Recommended setup&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Solo or small app&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;AGENTS.md&lt;/code&gt; + &lt;code&gt;CLAUDE.md&lt;/code&gt; with &lt;code&gt;@AGENTS.md&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Team Next.js app&lt;/td&gt;
&lt;td&gt;Above + glob-scoped &lt;code&gt;.cursor/rules/*.mdc&lt;/code&gt; and &lt;code&gt;.github/instructions/*&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Monorepo&lt;/td&gt;
&lt;td&gt;Root &lt;code&gt;AGENTS.md&lt;/code&gt; + nested &lt;code&gt;AGENTS.md&lt;/code&gt; per package + scoped rules&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you run your own agent tooling, the same idea applies to servers too. I wrote about exposing tools to agents in &lt;a href="https://www.fabwaseem.com/blog/build-mcp-server-nextjs" rel="noopener noreferrer"&gt;How to Build an MCP Server in Next.js&lt;/a&gt;, and about getting content cited by AI engines in &lt;a href="https://www.fabwaseem.com/blog/nextjs-seo-ai-search" rel="noopener noreferrer"&gt;Next.js SEO for AI Search&lt;/a&gt;. You can see more of what I build on the &lt;a href="https://www.fabwaseem.com/projects" rel="noopener noreferrer"&gt;projects page&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Related:&lt;/strong&gt; when a section of AGENTS.md grows into a multi-step procedure, move it into an Agent Skill that loads only when needed. See &lt;a href="https://www.fabwaseem.com/blog/agent-skills" rel="noopener noreferrer"&gt;Agent Skills: how to write a SKILL.md for Claude Code, Cursor, Codex and Copilot&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;AGENTS.md&lt;/code&gt; is the open, shared format. Use it for rules every agent should follow.&lt;/li&gt;
&lt;li&gt;Claude Code reads &lt;code&gt;AGENTS.md&lt;/code&gt; only when no &lt;code&gt;CLAUDE.md&lt;/code&gt; exists, so add &lt;code&gt;@AGENTS.md&lt;/code&gt; to your &lt;code&gt;CLAUDE.md&lt;/code&gt; if you keep both.&lt;/li&gt;
&lt;li&gt;Cursor needs &lt;code&gt;.mdc&lt;/code&gt; files in &lt;code&gt;.cursor/rules/&lt;/code&gt;, and globs keep rules out of unrelated conversations.&lt;/li&gt;
&lt;li&gt;Copilot path rules use &lt;code&gt;.instructions.md&lt;/code&gt; files with an &lt;code&gt;applyTo&lt;/code&gt; glob.&lt;/li&gt;
&lt;li&gt;Generate every format from one source so they never drift. &lt;a href="https://github.com/fabwaseem/agent-rules" rel="noopener noreferrer"&gt;agent-rules&lt;/a&gt; does this for the Next.js and TypeScript stack.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Is AGENTS.md the same as CLAUDE.md?
&lt;/h3&gt;

&lt;p&gt;No. AGENTS.md is an open format many coding agents read. CLAUDE.md is Claude Code's own project instructions file. Claude Code reads AGENTS.md only when no CLAUDE.md exists, so teams usually keep a short CLAUDE.md that imports AGENTS.md with an @AGENTS.md line.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I use AGENTS.md with Cursor?
&lt;/h3&gt;

&lt;p&gt;Yes. Cursor supports AGENTS.md as plain project instructions. For rules that should apply only to certain files, use .mdc files in .cursor/rules/ with a globs pattern, which Cursor attaches only when matching files are in context.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why is my Cursor rule not loading?
&lt;/h3&gt;

&lt;p&gt;The most common cause is the file extension. Cursor ignores .md files in .cursor/rules/; they must be .mdc. Also check the frontmatter: a rule with alwaysApply false and no globs or description only loads when you @ mention it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does GitHub Copilot read AGENTS.md?
&lt;/h3&gt;

&lt;p&gt;Yes. GitHub's docs say you can store AGENTS.md files anywhere in a repository and the nearest one takes precedence. Copilot also reads .github/copilot-instructions.md and path-specific .instructions.md files with an applyTo glob.&lt;/p&gt;

&lt;h3&gt;
  
  
  How long should AGENTS.md or CLAUDE.md be?
&lt;/h3&gt;

&lt;p&gt;Short. Anthropic recommends under 200 lines per CLAUDE.md because longer files cost context and reduce adherence. Keep commands, conventions and must-not-do rules at the root, and move file-specific rules into scoped Cursor or Copilot files.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is agent-rules free?
&lt;/h3&gt;

&lt;p&gt;Yes. agent-rules is MIT-licensed on GitHub at github.com/fabwaseem/agent-rules. Clone it and run the installer, or copy the rendered files from the examples folder. An npx installer is coming once the package is on npm.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.fabwaseem.com/blog/agents-md-vs-claude-md" rel="noopener noreferrer"&gt;fabwaseem.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>ai</category>
      <category>cursor</category>
      <category>productivity</category>
    </item>
  </channel>
</rss>
