<?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: Rabinarayan Patra</title>
    <description>The latest articles on DEV Community by Rabinarayan Patra (@rabinarayanpatra).</description>
    <link>https://dev.to/rabinarayanpatra</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%2F3866211%2F912ba316-ebe2-4423-82ef-f52f81e220c4.webp</url>
      <title>DEV Community: Rabinarayan Patra</title>
      <link>https://dev.to/rabinarayanpatra</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/rabinarayanpatra"/>
    <language>en</language>
    <item>
      <title>How to Configure flake8 for Python (2026 Setup Guide)</title>
      <dc:creator>Rabinarayan Patra</dc:creator>
      <pubDate>Tue, 07 Jul 2026 18:30:00 +0000</pubDate>
      <link>https://dev.to/rabinarayanpatra/how-to-configure-flake8-for-python-2026-setup-guide-4im5</link>
      <guid>https://dev.to/rabinarayanpatra/how-to-configure-flake8-for-python-2026-setup-guide-4im5</guid>
      <description>&lt;p&gt;Every Python project I join eventually hits the same wall. Someone adds flake8, drops the config into &lt;code&gt;pyproject.toml&lt;/code&gt; like every other modern tool expects, runs it, and nothing happens. No error, no warning, just flake8 quietly ignoring the entire file. Then the confusion starts.&lt;/p&gt;

&lt;p&gt;This guide walks through how to configure flake8 properly in 2026: what it actually is, where its config really lives, why &lt;code&gt;pyproject.toml&lt;/code&gt; is the one file it refuses to read, and whether you should still reach for it now that Ruff exists. Everything here is checked against flake8 7.3.0, released June 2025.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is flake8 and what does it actually check?
&lt;/h2&gt;

&lt;p&gt;flake8 is not one linter. It is a wrapper that glues three separate tools together behind a single command, then adds a plugin system on top. When you run &lt;code&gt;flake8&lt;/code&gt;, you are really running pyflakes, pycodestyle, and mccabe in one pass.&lt;/p&gt;

&lt;p&gt;Each tool owns a code prefix, and knowing them makes every flake8 report readable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;pyflakes&lt;/strong&gt; finds logical errors: unused imports, undefined names, unused variables. Its codes start with &lt;strong&gt;F&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;pycodestyle&lt;/strong&gt; checks PEP 8 style: whitespace, indentation, line length. It was once called pep8. Its codes start with &lt;strong&gt;E&lt;/strong&gt; (errors) and &lt;strong&gt;W&lt;/strong&gt; (warnings).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;mccabe&lt;/strong&gt; measures cyclomatic complexity. It emits exactly one code, &lt;strong&gt;C901&lt;/strong&gt; , and only when you turn it on.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;flake8 adds one code of its own: &lt;strong&gt;E999&lt;/strong&gt; , which means the file failed to compile at all (a syntax error). So when you see &lt;code&gt;F401&lt;/code&gt;, that is pyflakes telling you about an unused import. &lt;code&gt;E501&lt;/code&gt; is pycodestyle complaining about line length. &lt;code&gt;C901&lt;/code&gt; is mccabe saying a function is too complex.&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%2Fwww.rabinarayanpatra.com%2Fimages%2Fblogs%2Fhow-to-configure-flake8-python-cover.svg" 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%2Fwww.rabinarayanpatra.com%2Fimages%2Fblogs%2Fhow-to-configure-flake8-python-cover.svg" alt="flake8 glues pyflakes, pycodestyle, and mccabe into one command, each contributing its own error-code prefix" width="1600" height="900"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;flake8 7.3.0 pins those dependencies tightly: pyflakes 3.4.x, pycodestyle 2.14.x, and mccabe 0.7.x. It runs on Python 3.9 and up. The project lives under PyCQA (the Python Code Quality Authority) and is maintained by Anthony Sottile and Ian Cordasco.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you install and run flake8?
&lt;/h2&gt;

&lt;p&gt;Install it with pip and point it at your code. That is the whole starting workflow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;flake8
flake8 &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That runs flake8 across the current directory. You can target a specific path or module too:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;flake8 src/
python &lt;span class="nt"&gt;-m&lt;/span&gt; flake8 src/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;python -m flake8&lt;/code&gt; form is worth remembering. It guarantees you run the flake8 installed in the current environment, which matters the moment you have more than one Python around. On a fresh run with no config, flake8 uses its defaults: 79-character lines and a small built-in ignore list. Most teams change both, which is where config comes in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where does flake8 look for its configuration?
&lt;/h2&gt;

&lt;p&gt;flake8 reads three config files, all in INI format under a &lt;code&gt;[flake8]&lt;/code&gt; section: &lt;code&gt;setup.cfg&lt;/code&gt;, &lt;code&gt;tox.ini&lt;/code&gt;, and a dedicated &lt;code&gt;.flake8&lt;/code&gt; file. It does not read anything else, and it does not merge them. It picks one.&lt;/p&gt;

&lt;p&gt;I default to a standalone &lt;code&gt;.flake8&lt;/code&gt; file because it keeps linter config out of files that mean other things. Here is a realistic starting point, close to what the official docs recommend:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[flake8]&lt;/span&gt;
&lt;span class="py"&gt;max-line-length&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;88&lt;/span&gt;
&lt;span class="py"&gt;extend-ignore&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;E203&lt;/span&gt;
&lt;span class="py"&gt;exclude&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;.git, __pycache__ ,docs/source/conf.py,old,build,dist&lt;/span&gt;
&lt;span class="py"&gt;max-complexity&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;10&lt;/span&gt;
&lt;span class="py"&gt;per-file-ignores&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
    &lt;span class="err"&gt;__init__.py:&lt;/span&gt; &lt;span class="err"&gt;F401&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few of those options carry sharp edges, so here is what each one actually does:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;max-line-length&lt;/code&gt;&lt;/strong&gt; defaults to 79. Setting it to 88 matches Black, which is why most projects use that number.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;extend-ignore&lt;/code&gt;&lt;/strong&gt; adds codes to the ignore list without wiping the defaults. &lt;code&gt;ignore&lt;/code&gt; (no &lt;code&gt;extend-&lt;/code&gt;) replaces the entire default list, which usually is not what you want. Reach for the &lt;code&gt;extend-&lt;/code&gt; forms.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;extend-select&lt;/code&gt;&lt;/strong&gt; works the same way for turning checks on. It was added in flake8 4.0.0.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;exclude&lt;/code&gt;&lt;/strong&gt; takes comma-separated paths and globs that flake8 skips entirely.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;per-file-ignores&lt;/code&gt;&lt;/strong&gt; lets you relax rules per file. The &lt;code&gt;__init__.py: F401&lt;/code&gt; line above silences unused-import warnings in package init files, where re-exporting is normal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;max-complexity&lt;/code&gt;&lt;/strong&gt; turns on the mccabe check. It is off until you set it, so &lt;code&gt;C901&lt;/code&gt; never fires until you add a line like &lt;code&gt;max-complexity = 10&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;code&gt;extend-ignore = E203&lt;/code&gt; in that example is there for a reason. E203 flags whitespace before a colon, which collides with how Black formats slices. Turning it off is the standard fix for running flake8 and Black together.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why won't flake8 read your pyproject.toml?
&lt;/h2&gt;

&lt;p&gt;flake8 does not read &lt;code&gt;pyproject.toml&lt;/code&gt;, and that is a deliberate decision, not a missing feature. This is the single most common flake8 problem I see, and the answer surprises people: the maintainer has kept the request open for years and does not intend to add it as-is.&lt;/p&gt;

&lt;p&gt;The relevant thread is &lt;a href="https://github.com/PyCQA/flake8/issues/234" rel="noopener noreferrer"&gt;PyCQA/flake8 issue #234&lt;/a&gt;, and it is still open. Anthony Sottile's position is direct: "I don't think toml is the way especially with pip continuing to have weird behaviour when the file even exists." His technical objection is real. The mere presence of a &lt;code&gt;pyproject.toml&lt;/code&gt; file changes how pip builds a package, because it can force an isolated PEP 517 build. He listed two conditions that would need to hold before he would reconsider: pip no longer changing its behavior based on the file existing, and a standard-library TOML parser. Python 3.11 added &lt;code&gt;tomllib&lt;/code&gt;, so half of that is now true, but flake8 7.3.0 still will not read the file.&lt;/p&gt;

&lt;p&gt;So what do you do if your project standardizes on &lt;code&gt;pyproject.toml&lt;/code&gt; and you want flake8 config to live there too? Use a plugin.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;Flake8-pyproject
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://pypi.org/project/Flake8-pyproject/" rel="noopener noreferrer"&gt;Flake8-pyproject&lt;/a&gt; lets you put your config under a &lt;code&gt;[tool.flake8]&lt;/code&gt; section and either keeps working with the normal &lt;code&gt;flake8&lt;/code&gt; command or gives you a &lt;code&gt;flake8p&lt;/code&gt; entry point:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="nn"&gt;[tool.flake8]&lt;/span&gt;
&lt;span class="py"&gt;max-line-length&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;88&lt;/span&gt;
&lt;span class="py"&gt;extend-ignore&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"E203"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="py"&gt;exclude&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;".git"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;" __pycache__"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"build"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"dist"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is also &lt;code&gt;pyproject-flake8&lt;/code&gt; (the &lt;code&gt;pflake8&lt;/code&gt; command), which monkey-patches flake8 to read the file. Both work. I lean toward Flake8-pyproject because it is the lighter touch, but understand that either one is a workaround layered on top of a tool that, by design, still does not natively support the format.&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%2Fwww.rabinarayanpatra.com%2Fimages%2Fblogs%2Fhow-to-configure-flake8-python-config-resolution.svg" 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%2Fwww.rabinarayanpatra.com%2Fimages%2Fblogs%2Fhow-to-configure-flake8-python-config-resolution.svg" alt="flake8 reads setup.cfg, tox.ini, and .flake8 but not pyproject.toml, which needs the Flake8-pyproject plugin as a bridge" width="1600" height="900"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you silence a specific warning inline?
&lt;/h2&gt;

&lt;p&gt;Add a &lt;code&gt;# noqa&lt;/code&gt; comment to the line, and be specific about which code you are silencing. flake8 gives you two forms, and the difference matters.&lt;/p&gt;

&lt;p&gt;A bare &lt;code&gt;# noqa&lt;/code&gt; silences every error on that line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt; &lt;span class="c1"&gt;# noqa
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A coded &lt;code&gt;# noqa: E501&lt;/code&gt; silences only that check. Anything else on the line still gets reported, which is exactly what you want:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://example.com/a/very/long/path/that/breaks/the/line/limit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="c1"&gt;# noqa: E501
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can list several codes with commas, like &lt;code&gt;# noqa: E731,E123&lt;/code&gt;, and the comment is case-insensitive. I always prefer the coded form. A bare &lt;code&gt;# noqa&lt;/code&gt; is a blunt instrument that hides real bugs the day someone adds a second problem to that line. If you ever need to audit what your &lt;code&gt;# noqa&lt;/code&gt; comments are actually hiding, run with &lt;code&gt;--disable-noqa&lt;/code&gt; and flake8 reports everything as if the comments were not there.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you run flake8 in pre-commit and CI?
&lt;/h2&gt;

&lt;p&gt;Wire flake8 into pre-commit so it runs before code ever reaches a branch. The official hook is published in the flake8 repo, and the setup is a few lines in &lt;code&gt;.pre-commit-config.yaml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;repo&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://github.com/pycqa/flake8&lt;/span&gt;
    &lt;span class="s"&gt;rev&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;7.3.0&lt;/span&gt;
    &lt;span class="s"&gt;hooks&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;flake8&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The docs show &lt;code&gt;rev: ''&lt;/code&gt; as a placeholder, but always pin it to a real tag like &lt;code&gt;7.3.0&lt;/code&gt;. An unpinned linter version means your CI can start failing on a Tuesday because a new release added a check, and nobody changed a line of code. For CI itself, there is nothing special to configure. The same &lt;code&gt;flake8 .&lt;/code&gt; command that runs locally runs in a GitHub Actions step, reads the same config file, and exits non-zero on any violation. If you are hardening a pipeline, the same discipline I described in &lt;a href="https://www.rabinarayanpatra.com/blogs/how-to-enable-npm-trusted-publishing-github-actions-oidc" rel="noopener noreferrer"&gt;enabling npm trusted publishing with OIDC&lt;/a&gt; applies here: pin your tool versions and let the config file be the single source of truth.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which flake8 plugins are worth adding?
&lt;/h2&gt;

&lt;p&gt;Plugins extend flake8 with new checks under their own code prefixes, and installing one is just a pip install into the same environment. Most start reporting immediately, no config required. You can confirm what loaded by running &lt;code&gt;flake8 --version&lt;/code&gt;, which lists every active plugin.&lt;/p&gt;

&lt;p&gt;The ones I reach for most:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;flake8-bugbear&lt;/strong&gt; (prefix &lt;code&gt;B&lt;/code&gt;) catches likely bugs and questionable design that pyflakes misses, like mutable default arguments.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;flake8-comprehensions&lt;/strong&gt; (&lt;code&gt;C4&lt;/code&gt;) flags list and dict comprehensions that could be simpler.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;flake8-docstrings&lt;/strong&gt; (&lt;code&gt;D&lt;/code&gt;) checks PEP 257 docstring conventions by wrapping pydocstyle.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;pep8-naming&lt;/strong&gt; (&lt;code&gt;N&lt;/code&gt;) enforces naming conventions for classes, functions, and variables.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;flake8-bandit&lt;/strong&gt; adds security checks, and &lt;strong&gt;flake8-comprehensions&lt;/strong&gt; and &lt;strong&gt;flake8-simplify&lt;/strong&gt; clean up common code smells.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Once a plugin is installed, its codes behave like any other. Turn them on or off with &lt;code&gt;select&lt;/code&gt;, &lt;code&gt;ignore&lt;/code&gt;, &lt;code&gt;extend-select&lt;/code&gt;, and &lt;code&gt;extend-ignore&lt;/code&gt; in your config. That is the real strength of flake8: the checker set is yours to compose.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should you still use flake8 in 2026?
&lt;/h2&gt;

&lt;p&gt;Use flake8 if you depend on a plugin Ruff has not reimplemented. Otherwise, Ruff is probably the better default. That is the honest answer, and it comes straight from Ruff's own documentation rather than my opinion.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://docs.astral.sh/ruff/" rel="noopener noreferrer"&gt;Ruff&lt;/a&gt; is a linter written in Rust that advertises being "10-100x faster than existing linters (like Flake8)." Its FAQ is refreshingly precise about compatibility. Ruff is a drop-in replacement for flake8 when you use it without many plugins, alongside Black, on Python 3 code. Under those conditions it "implements every rule in Flake8," including all the pyflakes &lt;code&gt;F&lt;/code&gt; rules and a large subset of the &lt;code&gt;E&lt;/code&gt; and &lt;code&gt;W&lt;/code&gt; style rules.&lt;/p&gt;

&lt;p&gt;But Ruff names its own limit clearly: "Ruff's primary limitation vis-a-vis Flake8 is that it does not support custom lint rules." That is the line that keeps flake8 alive. If your team relies on an internal flake8 plugin, or a niche community one Ruff has not ported, flake8 is still the tool that can run it. The same goes for a stable, working setup where migration churn buys you nothing. Speed is real, but a linter you have already tuned and trust has value too.&lt;/p&gt;

&lt;p&gt;I have migrated projects to Ruff and kept others on flake8, and both calls were right for their context. The mistake is treating it as a moral question. Pick the tool that runs the checks you need, and let the benchmark difference decide only when the checks are equal.&lt;/p&gt;

&lt;p&gt;For the details behind everything here, see the &lt;a href="https://flake8.pycqa.org/en/latest/" rel="noopener noreferrer"&gt;flake8 documentation&lt;/a&gt;, the still-open &lt;a href="https://github.com/PyCQA/flake8/issues/234" rel="noopener noreferrer"&gt;pyproject.toml discussion in issue #234&lt;/a&gt;, and &lt;a href="https://docs.astral.sh/ruff/faq/" rel="noopener noreferrer"&gt;Ruff's FAQ&lt;/a&gt; on flake8 compatibility.&lt;/p&gt;

&lt;h3&gt;
  
  
  Keep Reading
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://www.rabinarayanpatra.com/blogs/how-to-enable-npm-trusted-publishing-github-actions-oidc" rel="noopener noreferrer"&gt;How to Enable npm Trusted Publishing with GitHub Actions OIDC&lt;/a&gt;. Another piece of a hardened CI pipeline, where pinned tool versions matter just as much.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.rabinarayanpatra.com/blogs/actions-checkout-v7-safe-pull-request-target" rel="noopener noreferrer"&gt;How to Check Out Fork PRs Safely in actions/checkout v7&lt;/a&gt;. Running linters on untrusted pull requests without opening a security hole.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.rabinarayanpatra.com/blogs/serve-react-spa-fastapi-app-frontend" rel="noopener noreferrer"&gt;How to Serve a React SPA from FastAPI with app.frontend()&lt;/a&gt;. More practical Python tooling, this time on the web-serving side.&lt;/li&gt;
&lt;/ul&gt;

</description>
    </item>
    <item>
      <title>HTTP QUERY Method: The Safe GET With a Body (RFC 10008)</title>
      <dc:creator>Rabinarayan Patra</dc:creator>
      <pubDate>Thu, 02 Jul 2026 18:30:00 +0000</pubDate>
      <link>https://dev.to/rabinarayanpatra/http-query-method-the-safe-get-with-a-body-rfc-10008-cli</link>
      <guid>https://dev.to/rabinarayanpatra/http-query-method-the-safe-get-with-a-body-rfc-10008-cli</guid>
      <description>&lt;p&gt;Every REST API I have ever worked on grows a &lt;code&gt;POST /search&lt;/code&gt; endpoint sooner or later. Not because POST is the right method for reading data, but because GET cannot carry a request body and the filter JSON stopped fitting in the URL years ago. As of mid-June 2026, HTTP finally has a proper fix. It is called QUERY, and it just became &lt;a href="https://www.rfc-editor.org/rfc/rfc10008.html" rel="noopener noreferrer"&gt;RFC 10008&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The HTTP QUERY method gives you the thing GET and POST each refuse to: a safe, idempotent, cacheable request that carries a body. In this post I will walk through what the spec actually says, why GET-with-a-body and POST-for-reads are both broken, how caching works when the cache key lives in the request body, and which runtimes you can use it on today.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is the HTTP QUERY method?
&lt;/h2&gt;

&lt;p&gt;The HTTP QUERY method is a new request method, standardized in RFC 10008 in mid-June 2026, that sends a query as the request body while keeping the guarantees of GET. The spec's abstract puts it well: "A QUERY requests that the request target process the enclosed content in a safe and idempotent manner and then respond with the result of that processing."&lt;/p&gt;

&lt;p&gt;The body is the query. Not the resource, not a command, a question. "The content of the request and its media type define the query." That media type matters: the spec says servers MUST fail the request if &lt;code&gt;Content-Type&lt;/code&gt; is missing or inconsistent with the content. A successful query comes back as a plain &lt;code&gt;200 OK&lt;/code&gt; with the results in the response body.&lt;/p&gt;

&lt;p&gt;Here is the comparison table straight from Section 1 of the RFC:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Property&lt;/th&gt;
&lt;th&gt;GET&lt;/th&gt;
&lt;th&gt;QUERY&lt;/th&gt;
&lt;th&gt;POST&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Safe&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Potentially no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Idempotent&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cacheable&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Only for future GET or HEAD requests&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Request body&lt;/td&gt;
&lt;td&gt;No defined semantics&lt;/td&gt;
&lt;td&gt;Expected&lt;/td&gt;
&lt;td&gt;Expected&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;There is also a discovery mechanism. A server can advertise QUERY support with the new &lt;code&gt;Accept-Query&lt;/code&gt; response header, a Structured Field listing the query media types it accepts for that path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;Accept-Query: "application/jsonpath", application/sql;charset="UTF-8"
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The RFC was written by Julian Reschke (greenbytes), James M. Snell (Cloudflare), and Mike Bishop (Akamai), and it is a Proposed Standard on the IETF standards track. This is not a vendor experiment. It is the real thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why isn't GET with a body enough?
&lt;/h2&gt;

&lt;p&gt;Sending a body with GET is undefined behavior that some servers and intermediaries will actively reject. RFC 9110, the core HTTP semantics spec, is blunt about it in Section 9.3.1: "content received in a GET request has no generally defined semantics, cannot alter the meaning or target of the request, and might lead some implementations to reject the request and close the connection because of its potential as a request smuggling attack."&lt;/p&gt;

&lt;p&gt;So the standard advice has always been: put the query in the URL. That works until it does not. RFC 9110 only recommends that implementations support URIs of at least 8,000 octets, and that is a floor, not a promise. Real limits vary per proxy, per load balancer, per server, and you discover the smallest one in the chain at runtime. The spec for the 414 status code even names the workaround as the culprit: it happens "when a client has improperly converted a POST request to a GET request with long query information."&lt;/p&gt;

&lt;p&gt;I have debugged exactly one 414 in my career and it cost me most of a day, because the request worked fine locally and only failed behind a corporate proxy with a tighter URI limit. That is the nature of this failure: it is environmental, and your test suite will not catch it.&lt;/p&gt;

&lt;p&gt;RFC 10008's introduction lists two more problems with query-in-the-URL that I find underrated. First, URLs leak: "request URIs are more likely to be logged than request content and may also turn up in bookmarks." If your query contains anything sensitive, it is now in every access log along the path. Second, my favorite line in the whole spec: encoding queries into the URI "effectively casts every possible combination of query inputs as distinct resources." Your cache and your metrics now treat &lt;code&gt;?city=Berlin&amp;amp;limit=50&lt;/code&gt; and &lt;code&gt;?limit=50&amp;amp;city=Berlin&lt;/code&gt; as different things, even though they are the same question.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not just use POST for queries?
&lt;/h2&gt;

&lt;p&gt;POST works for queries right up until you need retries, caching, or honest semantics. The problem is not that POST fails. It is that POST tells every piece of HTTP infrastructure to assume the worst.&lt;/p&gt;

&lt;p&gt;POST is not safe and not idempotent. When a connection drops mid-request, neither the client nor any proxy can know whether state changed on the server. So nothing retries automatically. Compare that with the QUERY abstract: QUERY requests "can be automatically repeated or restarted without concern for partial state changes." For genuinely unsafe operations you end up building retry safety by hand, which is exactly the machinery I covered in &lt;a href="https://www.rabinarayanpatra.com/blogs/implement-idempotency-keys-rest-api" rel="noopener noreferrer"&gt;idempotency keys for REST APIs&lt;/a&gt;. Reads should never have needed that machinery in the first place.&lt;/p&gt;

&lt;p&gt;And then there is caching. RFC 9110 Section 9.3.3 allows caching POST responses only when the response carries explicit freshness information plus a &lt;code&gt;Content-Location&lt;/code&gt; that matches the target URI, and even then the cached response can only serve future GET and HEAD requests. It says directly: "a POST request cannot be satisfied by a cached POST response because POST is potentially unsafe." Two identical &lt;code&gt;POST /search&lt;/code&gt; calls always hit your origin. Every dashboard that polls a search endpoint is doing full-price origin work on every tick.&lt;/p&gt;

&lt;p&gt;QUERY flips all three defaults. Safe, so infrastructure can retry it. Idempotent, so retries need no bookkeeping. Cacheable, so identical questions can be answered without recomputing.&lt;/p&gt;

&lt;h2&gt;
  
  
  How does caching work for QUERY requests?
&lt;/h2&gt;

&lt;p&gt;A QUERY response is cacheable, but the cache key must include the request content, not just the URI. That is the core trade the spec makes, and Section 2.7 is explicit: the cache key "MUST incorporate the request content" and related metadata.&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%2Fwww.rabinarayanpatra.com%2Fimages%2Fblogs%2Fhttp-query-method-rfc-10008-cache-key.svg" 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%2Fwww.rabinarayanpatra.com%2Fimages%2Fblogs%2Fhttp-query-method-rfc-10008-cache-key.svg" alt="Diagram of a QUERY request cache key built from the method, target URI, and normalized request body, showing a cache miss followed by a cache hit"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is genuinely harder than caching GET. The spec admits it: "caching QUERY method responses is inherently more complex than caching responses to GET, as complete reading of the request's content is needed in order to determine the cache key." A cache has to buffer the body before it can even decide whether it has seen this request before.&lt;/p&gt;

&lt;p&gt;To avoid trivial misses, caches are allowed to normalize: they "MAY remove semantically insignificant differences from request content." The spec calls out stripping content encodings and using media type suffix knowledge, like treating anything ending in &lt;code&gt;+json&lt;/code&gt; as JSON for normalization purposes. The flip side lands in the security section: normalize too aggressively and "normalization results in a false positive," meaning a cache can hand back the wrong query's results. If you build or configure a QUERY-aware cache, that is the failure mode to fear.&lt;/p&gt;

&lt;p&gt;Conditional requests work too, through a clever indirection the spec calls the equivalent resource: a hypothetical GET-able resource derived by incorporating the request content into the target. Your &lt;code&gt;ETag&lt;/code&gt; and &lt;code&gt;If-None-Match&lt;/code&gt; validators operate against that, so a repeated query can come back &lt;code&gt;304 Not Modified&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Two response headers round out the model, and they are easy to confuse:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Content-Location&lt;/code&gt; points at the query results, a URI you can plainly GET later to retrieve the same representation.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Location&lt;/code&gt; points at the query itself, a URI that re-runs the query on GET without resending the body.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One redirect trap worth knowing: the historical browser behavior of rewriting a redirected POST into a GET does not apply here. The spec states the 301/302 exceptions for POST do not apply to QUERY. Only a 303 tells the client to fetch the result with GET.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why was it named QUERY instead of SEARCH?
&lt;/h2&gt;

&lt;p&gt;The spec spent its first six years named SEARCH and dropped the name to escape WebDAV baggage. The idea goes back to a 2015 individual draft (draft-snell-search-method), got revived by Asbjorn Ulsberg at the HTTP Workshop in 2019, and was adopted by the httpbis working group in March 2021, still called SEARCH.&lt;/p&gt;

&lt;p&gt;The rename happened in November 2021. Appendix B of the RFC explains the reasoning with unusual candor. The IANA method registry already had three safe, idempotent methods: PROPFIND, REPORT, and SEARCH. All three come out of the WebDAV effort, "about which many have mixed feelings," and all use generic XML request formats. QUERY got a clean start, and as the appendix notes, the name "captures the relation with the URI's query component well."&lt;/p&gt;

&lt;p&gt;From there it was a slow march: final draft in November 2025, IESG approval on November 20, 2025, and publication as RFC 10008 in mid-June 2026. Eleven years from first draft to standard. HTTP does not move fast, which is exactly why it is worth paying attention when it does move.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who supports the HTTP QUERY method in 2026?
&lt;/h2&gt;

&lt;p&gt;Node.js has parsed QUERY natively since early 2024, OpenAPI 3.2 can document it, and Spring's support is an open pull request. Here is the honest state of things as of July 2026:&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;Status (July 2026)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Node.js&lt;/td&gt;
&lt;td&gt;Native. QUERY parsing landed via llhttp 9.2.0 in Node 21.7.2 and Node 22+&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Express&lt;/td&gt;
&lt;td&gt;Works on QUERY-capable Node, but no TypeScript types yet&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fastify&lt;/td&gt;
&lt;td&gt;Opt-in: &lt;code&gt;fastify.addHttpMethod('QUERY', { hasBody: true })&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Go net/http&lt;/td&gt;
&lt;td&gt;No &lt;code&gt;MethodQuery&lt;/code&gt; constant, but arbitrary method strings work today&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Spring Framework&lt;/td&gt;
&lt;td&gt;Not shipped. PR #34993 is open and currently labeled blocked&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ASP.NET Core&lt;/td&gt;
&lt;td&gt;.NET 11 Preview 4 maps QUERY and emits it in OpenAPI output&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenAPI&lt;/td&gt;
&lt;td&gt;3.2.0 (September 2025) added a first-class &lt;code&gt;query&lt;/code&gt; operation field&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;curl&lt;/td&gt;
&lt;td&gt;Generic &lt;code&gt;-X QUERY&lt;/code&gt; with &lt;code&gt;--data&lt;/code&gt; and an explicit &lt;code&gt;Content-Type&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Browsers (fetch)&lt;/td&gt;
&lt;td&gt;Accepted as a method string, but always triggers a CORS preflight&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A few of these deserve context. The Spring situation is the one I am watching closest as a Java developer: the original issue (#32975) sat for two years and was superseded by &lt;a href="https://github.com/spring-projects/spring-framework/pull/34993" rel="noopener noreferrer"&gt;PR #34993&lt;/a&gt;, which the author marked ready for review the same week the RFC published. As of early July 2026 the &lt;code&gt;RequestMethod&lt;/code&gt; enum still has no QUERY, so Spring apps cannot route it through &lt;code&gt;@RequestMapping&lt;/code&gt; yet.&lt;/p&gt;

&lt;p&gt;On the browser side, QUERY is not a CORS-safelisted method, and the RFC says plainly that a cross-origin QUERY "will require a preflight request." Budget for the extra OPTIONS round trip, and if preflights have bitten you before, my &lt;a href="https://www.rabinarayanpatra.com/blogs/spring-boot-cors-guide" rel="noopener noreferrer"&gt;Spring Boot CORS guide&lt;/a&gt; covers how to configure them properly. MDN has no QUERY page yet and there is no caniuse entry, which tells you how early we still are.&lt;/p&gt;

&lt;p&gt;And do not assume CDN support. Two of the RFC's three authors work at Cloudflare and Akamai, which bodes well, but I could not find any announcement from either that their edge caches handle QUERY today.&lt;/p&gt;

&lt;h2&gt;
  
  
  How can you try the QUERY method today?
&lt;/h2&gt;

&lt;p&gt;curl plus Node 22 is enough to build a working QUERY endpoint right now, no dependencies required. curl sends any method you give it:&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;-X&lt;/span&gt; QUERY &lt;span class="s1"&gt;'http://localhost:3000/contacts'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"filter": {"city": "Berlin"}, "limit": 50}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the server side, Node's HTTP parser accepts QUERY natively, so &lt;code&gt;req.method&lt;/code&gt; just works:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createServer&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:http&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;server&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createServer&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;res&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;if &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;method&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;QUERY&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;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeHead&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;405&lt;/span&gt;&lt;span class="p"&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;QUERY&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end&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="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;''&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="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;data&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;chunk&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;body&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;chunk&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="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;end&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="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="nx"&gt;query&lt;/span&gt; &lt;span class="o"&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;parse&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="c1"&gt;// Run the actual search here. The body IS the query.&lt;/span&gt;
    &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeHead&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&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&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end&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;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;results&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="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From a browser or any JavaScript runtime, &lt;code&gt;fetch&lt;/code&gt; takes QUERY as a plain method string:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&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;http://localhost:3000/contacts&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;QUERY&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&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;filter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;city&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Berlin&lt;/span&gt;&lt;span class="dl"&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;50&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;results&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;res&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Remember the preflight: cross-origin, this fires an OPTIONS request first, every time, because QUERY is not safelisted.&lt;/p&gt;

&lt;p&gt;If you are on Spring, you are stuck waiting or working around. Since &lt;code&gt;RequestMethod&lt;/code&gt; does not know QUERY, requests with the method never reach your controllers through normal mapping. A servlet filter registered ahead of Spring MVC can intercept &lt;code&gt;"QUERY".equals(request.getMethod())&lt;/code&gt; and dispatch manually, but I would treat that as a stopgap and keep an eye on PR #34993 instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should you adopt QUERY now?
&lt;/h2&gt;

&lt;p&gt;For internal services on Node, I would start using QUERY today. The parser support is there, the semantics are standardized, and internal traffic does not care about CDN or browser maturity. Every retry policy and cache layer you control can start taking advantage of safe-and-idempotent reads immediately.&lt;/p&gt;

&lt;p&gt;For public APIs, advertise before you commit. Ship &lt;code&gt;Accept-Query&lt;/code&gt; on endpoints that support it, keep the POST variant alive for older clients, and let OpenAPI 3.2 document both. The tooling wave is coming from an interesting direction: with OpenAPI 3.2 and ASP.NET 11 both recognizing QUERY, generated clients will start offering it before most hand-written servers do.&lt;/p&gt;

&lt;p&gt;The 420-point Hacker News thread that greeted the RFC in June 2026 suggests developers have been waiting for this one. I think &lt;code&gt;POST /search&lt;/code&gt; will read as a legacy idiom within a few years, the same way &lt;code&gt;X-&lt;/code&gt; headers do now. The method finally matches the meaning.&lt;/p&gt;

&lt;p&gt;For the primary sources, read &lt;a href="https://www.rfc-editor.org/rfc/rfc10008.html" rel="noopener noreferrer"&gt;RFC 10008&lt;/a&gt; itself, the HTTP semantics it builds on in &lt;a href="https://www.rfc-editor.org/rfc/rfc9110.html" rel="noopener noreferrer"&gt;RFC 9110&lt;/a&gt;, and the &lt;a href="https://www.openapis.org/blog/2025/09/23/announcing-openapi-v3-2" rel="noopener noreferrer"&gt;OpenAPI 3.2.0 announcement&lt;/a&gt; that added first-class QUERY support.&lt;/p&gt;

&lt;h3&gt;
  
  
  Keep Reading
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://www.rabinarayanpatra.com/blogs/implement-idempotency-keys-rest-api" rel="noopener noreferrer"&gt;How to Implement Idempotency Keys in REST APIs the Right Way&lt;/a&gt;. QUERY gives reads the retry safety that writes still have to build by hand.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.rabinarayanpatra.com/blogs/spring-boot-cors-guide" rel="noopener noreferrer"&gt;Solving Spring Boot CORS Errors Once and For All&lt;/a&gt;. Every cross-origin QUERY triggers a preflight, so get your CORS config right first.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.rabinarayanpatra.com/blogs/how-to-version-rest-apis-spring-framework-7" rel="noopener noreferrer"&gt;How to Version REST APIs in Spring Framework 7 (Spring Boot 4)&lt;/a&gt;. More API design decisions in the current Spring generation.&lt;/li&gt;
&lt;/ul&gt;

</description>
    </item>
    <item>
      <title>iOS 27 for Developers: Breaking Changes and New APIs</title>
      <dc:creator>Rabinarayan Patra</dc:creator>
      <pubDate>Tue, 23 Jun 2026 18:30:00 +0000</pubDate>
      <link>https://dev.to/rabinarayanpatra/ios-27-for-developers-breaking-changes-and-new-apis-5gd9</link>
      <guid>https://dev.to/rabinarayanpatra/ios-27-for-developers-breaking-changes-and-new-apis-5gd9</guid>
      <description>&lt;p&gt;Apple announced iOS 27 at the WWDC keynote on June 8, 2026, and the developer betas went out the same week. Most of the coverage you've seen is about Liquid Glass getting prettier and Siri getting smarter. That's the consumer story. The developer story is sharper: open your project in Xcode 27, hit build, and a couple of things that used to be warnings are now hard failures.&lt;/p&gt;

&lt;p&gt;I went through the iOS 27 beta release notes, the developer documentation, and Apple's own developer newsroom posts to pull out what actually matters when you compile. This is a developer spec, not a feature reel. We'll cover the breaking changes that can reject your app, the deprecations you should start migrating off, the new design rules you can no longer skip, and the genuinely interesting new frameworks (Core AI, Foundation Models, and a pile of smaller ones). Where Apple has not committed to something publicly, I'll say so instead of guessing.&lt;/p&gt;

&lt;h2&gt;
  
  
  When does iOS 27 ship and what do you build it with?
&lt;/h2&gt;

&lt;p&gt;iOS 27 ships in the usual rhythm: betas now, public beta in July, and general release expected in September 2026, though Apple has not published an exact date. The whole family moved together, so iPadOS 27, macOS 27, watchOS 27, tvOS 27, and visionOS 27 all landed at the same keynote.&lt;/p&gt;

&lt;p&gt;The toolchain is where the practical facts are. iOS 27 builds with &lt;strong&gt;Xcode 27&lt;/strong&gt; , which bundles the iOS and iPadOS 27 SDK and runs the &lt;strong&gt;Swift 6.4&lt;/strong&gt; compiler. Two things about Xcode 27 are worth knowing before you download it: it's &lt;strong&gt;Apple-silicon only&lt;/strong&gt; now (Intel Macs can't run it), and the install is about &lt;strong&gt;30% smaller&lt;/strong&gt; than Xcode 26.&lt;/p&gt;

&lt;p&gt;Here's the part that saves you from panic: there is no deadline forcing you onto the iOS 27 SDK yet. Apple's submission floor is still the iOS 26 SDK with Xcode 26, which became mandatory on April 28, 2026. The "Upcoming Requirements" page lists nothing past that. So you don't have to ship against iOS 27 in a hurry. You should still test against it early, because the breaking changes below are easier to fix in June than in a September release scramble.&lt;/p&gt;

&lt;h2&gt;
  
  
  What breaks when you recompile against the iOS 27 SDK?
&lt;/h2&gt;

&lt;p&gt;Two changes are hard gates that stop your app cold, and a few more are quieter source-level breaks. Start with the two that matter most, because they're the ones that turn a routine recompile into a rejected build or a crash on launch.&lt;/p&gt;

&lt;p&gt;First, a &lt;strong&gt;launch screen is now required&lt;/strong&gt;. Apps built with the 27.0 SDK must declare one of &lt;code&gt;UILaunchScreen&lt;/code&gt;, &lt;code&gt;UILaunchStoryboardName&lt;/code&gt;, &lt;code&gt;UILaunchScreens&lt;/code&gt;, or &lt;code&gt;UILaunchStoryboards&lt;/code&gt; in &lt;code&gt;Info.plist&lt;/code&gt;, or the App Store rejects the upload. Second, the &lt;strong&gt;scene-based lifecycle is now required&lt;/strong&gt;. If your app still boots through the old app-based lifecycle without a scene manifest, it fails to launch on iOS 27.&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%2Fevmmka82zw40mza2cqou.webp" 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%2Fevmmka82zw40mza2cqou.webp" alt="Checklist of iOS 27 SDK build gates: launch screen required, scene-based lifecycle required, and the State macro change" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you've been putting off scene adoption, this is the forcing function. The fix is a &lt;code&gt;UIApplicationSceneManifest&lt;/code&gt; in your &lt;code&gt;Info.plist&lt;/code&gt; and a &lt;code&gt;UIWindowSceneDelegate&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;UIApplicationSceneManifest&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;dict&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;UIApplicationSupportsMultipleScenes&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;true/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;UISceneConfigurations&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;dict&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;UIWindowSceneSessionRoleApplication&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;array&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;dict&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;UISceneConfigurationName&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;Default Configuration&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;UISceneDelegateClassName&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;$(PRODUCT_MODULE_NAME).SceneDelegate&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;/dict&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/array&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/dict&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/dict&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The next one bites SwiftUI code. &lt;strong&gt;&lt;code&gt;@State&lt;/code&gt; is now a macro.&lt;/strong&gt; Apple calls it source-compatible "with exceptions," and the exceptions are real. You can no longer set an initial value both at the declaration and in &lt;code&gt;init&lt;/code&gt; (the &lt;code&gt;init&lt;/code&gt; value gets discarded), the auto-synthesized &lt;code&gt;init&lt;/code&gt; is disabled for private members using &lt;code&gt;@State&lt;/code&gt;, generic inference is weaker, and &lt;code&gt;@State&lt;/code&gt; won't compose with other property wrappers. So this pattern stops doing what you think:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;struct&lt;/span&gt; &lt;span class="kt"&gt;CounterView&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;View&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;@State&lt;/span&gt; &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="c1"&gt;// declaration value&lt;/span&gt;

    &lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;startingAt&lt;/span&gt; &lt;span class="nv"&gt;count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;_count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;State&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;initialValue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// discarded under the macro&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's also a cluster of UIKit changes. &lt;code&gt;UIApplication.statusBarFrame&lt;/code&gt;, &lt;code&gt;statusBarOrientation&lt;/code&gt;, &lt;code&gt;statusBarStyle&lt;/code&gt;, and &lt;code&gt;isStatusBarHidden&lt;/code&gt; are deprecated and can now return NaN or null, so read from the window scene instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Deprecated in iOS 27&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;style&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;UIApplication&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shared&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;statusBarStyle&lt;/span&gt;

&lt;span class="c1"&gt;// Use the scene's status bar manager&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;style&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;view&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;window&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;windowScene&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;statusBarManager&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;statusBarStyle&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few more to scan for: menu item images are &lt;strong&gt;hidden by default&lt;/strong&gt; on iPadOS and macOS 27 (set &lt;code&gt;preferredImageVisibility&lt;/code&gt; on &lt;code&gt;UIMenuElement&lt;/code&gt; to bring them back), &lt;code&gt;UISearchController&lt;/code&gt; center placement now renders the scope bar inline with the search field, and UIKit presentation trait inheritance now walks the superview chain instead of jumping to the presentation controller, so custom &lt;code&gt;UIPresentationController&lt;/code&gt; subclasses may need a look.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which APIs and frameworks are deprecated in iOS 27?
&lt;/h2&gt;

&lt;p&gt;Three deprecations are worth acting on now, even though none of them break your build today. Deprecations are free to ignore until the year they aren't, and these all have clean replacements already shipping.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Deprecated&lt;/th&gt;
&lt;th&gt;Replacement&lt;/th&gt;
&lt;th&gt;Why it matters&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;On Demand Resources, &lt;code&gt;NSBundleResourceRequest&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Background Assets&lt;/td&gt;
&lt;td&gt;ODR is the old way to stage large assets. Background Assets is the supported path going forward.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Original MetricKit (&lt;code&gt;MXMetricManager&lt;/code&gt;, &lt;code&gt;MXMetricPayload&lt;/code&gt;, &lt;code&gt;MXDiagnosticPayload&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;MetricManager&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;If you collect launch time, hang rate, or crash diagnostics, move to the new manager.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;UIApplication&lt;/code&gt; status-bar accessors&lt;/td&gt;
&lt;td&gt;&lt;code&gt;UIWindowScene.statusBarManager&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Covered above. Also formally deprecated, not just discouraged.&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%2Fc1wy5obtd8azvn33aacs.webp" 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%2Fc1wy5obtd8azvn33aacs.webp" alt="Table of iOS 27 deprecations mapping On Demand Resources to Background Assets, original MetricKit to MetricManager, and UIApplication status bar accessors to the window scene status bar manager" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;There's one Swift toolchain trap that isn't a framework deprecation but will waste your afternoon if you hit it. Swift 6.4 adds new &lt;code&gt;stat()&lt;/code&gt; instance methods on &lt;code&gt;FilePath&lt;/code&gt; and &lt;code&gt;FileDescriptor&lt;/code&gt;. If you wrote your own extensions that call an unqualified &lt;code&gt;stat()&lt;/code&gt;, the call can now resolve to the wrong thing. Disambiguate with &lt;code&gt;Darwin.stat()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;On the security side, iOS 27 tightens TLS in a set of system processes (MDM, declarative device management, Automated Device Enrollment, profile install, app install, and software updates). Those servers now need to support &lt;strong&gt;TLS 1.2 minimum&lt;/strong&gt; with App Transport Security compliant cipher suites and certificates, or the connections fail. If you run enterprise device management infrastructure, this is the line item to verify before your fleet updates.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is Liquid Glass and why can't you opt out anymore?
&lt;/h2&gt;

&lt;p&gt;Liquid Glass is Apple's system design language from iOS 26, and in iOS 27 it's refined rather than renamed, with one change that affects every app: you can no longer opt out. iOS 26 shipped a temporary &lt;code&gt;Info.plist&lt;/code&gt; key that let apps keep the old look while they adapted. iOS 27 removes that escape hatch. Any app recompiled with Xcode 27 automatically adopts the new design.&lt;/p&gt;

&lt;p&gt;What "refined" means in practice: refreshed system materials, updated typography, and reworked tab bars and navigation bars. App icons render sharper automatically, and users get a transparency slider in Settings to tune the effect. None of that is opt-in. So if your layout leaned on the old material blur or assumed specific bar metrics, test it against the beta now.&lt;/p&gt;

&lt;p&gt;There's a second design change that's easy to miss because it sounds like a feature: &lt;strong&gt;iOS apps are now resizable&lt;/strong&gt; , both on large iPad displays and through iPhone Mirroring on the Mac. Resizable means your layout has to respond to live size changes, not just rotation. If you have hard-coded frames or assumed a fixed width, this is the moment that breaks. It's the same discipline good adaptive layouts already follow, but iOS 27 makes it non-optional for more surfaces.&lt;/p&gt;

&lt;h2&gt;
  
  
  What can you build with Core AI and the Foundation Models framework?
&lt;/h2&gt;

&lt;p&gt;iOS 27 splits on-device AI into two layers: &lt;strong&gt;Foundation Models&lt;/strong&gt; is the high-level Swift API for generating content, and &lt;strong&gt;Core AI&lt;/strong&gt; is the lower-level framework for running your own models. They're different tools, and the distinction matters when you decide what to build.&lt;/p&gt;

&lt;p&gt;Foundation Models is now a single Swift generation API that spans the on-device system language model, Private Cloud Compute models, Core AI, and MLX, plus third-party providers through a new &lt;strong&gt;Language Model protocol&lt;/strong&gt;. In iOS 27 it adds image input (so it's multimodal), server model support, and Dynamic Profiles, which let you swap the model, tools, or instructions mid-session. Apple also said the framework is going open source later in summer 2026, with the same Swift APIs running server-side. The basic shape is the same friendly session you may have seen in iOS 26:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;import&lt;/span&gt; &lt;span class="kt"&gt;FoundationModels&lt;/span&gt;

&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;LanguageModelSession&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;reply&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;respond&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nv"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"Summarize this support ticket in one sentence."&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reply&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Guided generation still uses the &lt;code&gt;@Generable&lt;/code&gt; macro to get typed structs back instead of parsing strings, and tool calling lets the model invoke your functions (including on-device Vision tools like OCR and barcode reading). The exact signatures are still moving during beta, so check the current docs before you wire anything load-bearing.&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%2Fwnc6caqr50eds04mxs7f.webp" 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%2Fwnc6caqr50eds04mxs7f.webp" alt="Architecture diagram showing the Foundation Models Swift API routing to on-device system model, Private Cloud Compute, Core AI, MLX, and external providers like Claude and Gemini" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Core AI is the bring-your-own-model path. It's built into the OS, tuned for Apple silicon, and lets you load, specialize, and run custom models fully on-device with ahead-of-time compilation. Apple ships Python tools to convert PyTorch models to Apple silicon, and Core AI is what powers the new on-device Siri under the hood. If you've been running a model through a third-party runtime to keep it on-device, this is the native option.&lt;/p&gt;

&lt;p&gt;One pricing fact that's genuinely useful: developers in the &lt;strong&gt;Small Business Program&lt;/strong&gt; (under 2 million lifetime first-time downloads) get the next-generation Apple Foundation Models on Private Cloud Compute at no cloud API cost. That's a real lever for indie and small-team apps that want server-grade inference without a per-token bill. If you've been comparing this with wiring up your own model router, my take on &lt;a href="https://www.rabinarayanpatra.com/blogs/claude-skills-vs-mcp-vs-projects" rel="noopener noreferrer"&gt;Claude Skills vs MCP vs Projects&lt;/a&gt; covers how to think about where the model logic should live.&lt;/p&gt;

&lt;h2&gt;
  
  
  What new frameworks ship in iOS 27 beyond AI?
&lt;/h2&gt;

&lt;p&gt;Plenty, and several are the kind of thing you'd otherwise build yourself. Here are the ones worth knowing about, grouped by what they do.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Evaluations framework&lt;/strong&gt; : validates AI feature behavior across changing conditions, going beyond unit tests. If you ship anything model-driven, this is how Apple wants you to test it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;App Intents Testing framework&lt;/strong&gt; : validates App Intents through real system pathways without UI automation, which has been a pain point for years.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;App Intents (expanded)&lt;/strong&gt;: adds entity schemas that contribute content to the Spotlight semantic index, intent schemas for natural-language actions without predefined phrases, and a View Annotations API that maps views to entities so Siri can reference what's on screen.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Music Understanding framework&lt;/strong&gt; : analyzes audio across six dimensions on-device.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;NowPlaying framework&lt;/strong&gt; : connects your app's playback to the Lock Screen, Control Center, Dynamic Island, and CarPlay through one API.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SwiftUI additions&lt;/strong&gt; : reorderable containers (drag to reorder across lists and grids), document-based apps with direct disk access, lazily loaded subviews that prefetch for smoother scrolling, and a Spatial Preview framework for 3D model viewing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WidgetKit&lt;/strong&gt; : widgets are now customizable through App Intents with dynamic styling.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Image Playground API&lt;/strong&gt; : the generative model is reimagined on Private Cloud Compute and can produce photorealistic images in-app.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;App Intents is the one I'd prioritize. It's the connective tissue between your app and Siri's new personal context and on-screen awareness, and the entity and intent schemas are how your content shows up in places you don't control directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  How does Xcode 27 change your build and test loop?
&lt;/h2&gt;

&lt;p&gt;Xcode 27 changes two habits: the Simulator is gone, and coding agents are built in. The first is structural. &lt;strong&gt;Device Hub replaces the Simulator&lt;/strong&gt; , unifying virtual and physical devices in one place so you diagnose and reproduce issues across both from inside the IDE. If your scripts or CI reference the Simulator by name, that's a migration to plan.&lt;/p&gt;

&lt;p&gt;The second is the agentic coding story. Xcode 27 integrates agents from Anthropic, Google, and OpenAI that can run your tests, run the app in the new Device Hub, try code in a Playground, pull a crash from the Organizer and fix it, and localize your app. It's extensible too, through plugins and MCP tools and the Agent Client Protocol, with GitHub and Figma as first-party plugin installs. There's also an &lt;code&gt;fm&lt;/code&gt; command-line tool and a Python SDK for scripting Foundation Models outside the app. If you already work with CLI coding agents, the &lt;a href="https://www.rabinarayanpatra.com/blogs/best-claude-skills-for-developers-2026" rel="noopener noreferrer"&gt;best Claude skills for developers&lt;/a&gt; translate cleanly to this workflow.&lt;/p&gt;

&lt;p&gt;The smaller quality-of-life wins add up: iCloud settings sync so your Xcode setup follows you across Macs, a fully customizable toolbar, app-wide color themes, faster project loads, and Xcode Cloud builds up to twice as fast. Apple also mentioned that parts of the OS kernel are now written in Swift, which is more of a milestone than a developer-facing API, but it tells you where the platform is heading.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should you adopt iOS 27 now or wait?
&lt;/h2&gt;

&lt;p&gt;Wait to ship against it, but test against it this week. Since there's no deadline forcing the iOS 27 SDK, you don't need to rush a release. But the two hard build gates (launch screen and scene-based lifecycle) plus the &lt;code&gt;@State&lt;/code&gt; macro change are exactly the kind of thing that's a calm afternoon now and a fire drill the week of general release. Pull the beta, recompile, and fix the build failures while there's no pressure.&lt;/p&gt;

&lt;p&gt;The more interesting question is what iOS 27 unlocks. Core AI plus free Private Cloud Compute for small developers is the first time running real models, on-device or server-side, is a default capability instead of a research project. If you've been waiting for a reason to put a model in your app without a cloud bill, this is it. The breaking changes are the cost of entry. The AI frameworks are why you'd want to pay it.&lt;/p&gt;

&lt;p&gt;For the primary details, see Apple's &lt;a href="https://developer.apple.com/ios/whats-new/" rel="noopener noreferrer"&gt;iOS 27 what's new for developers&lt;/a&gt;, the &lt;a href="https://developer.apple.com/documentation/ios-ipados-release-notes/ios-ipados-27-release-notes" rel="noopener noreferrer"&gt;iOS and iPadOS 27 release notes&lt;/a&gt;, and Apple's developer newsroom post on the &lt;a href="https://www.apple.com/newsroom/2026/06/apple-aids-app-development-with-new-intelligence-frameworks-and-advanced-tools/" rel="noopener noreferrer"&gt;new intelligence frameworks and tools&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Keep Reading
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://www.rabinarayanpatra.com/blogs/branch-io-deep-linking-attribution-guide" rel="noopener noreferrer"&gt;Branch.io Deep Linking and Mobile Attribution Explained&lt;/a&gt;. The mobile-growth side of shipping apps, once your iOS 27 build is clean.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.rabinarayanpatra.com/blogs/claude-skills-vs-mcp-vs-projects" rel="noopener noreferrer"&gt;Claude Skills vs MCP vs Projects: Which One Should You Use?&lt;/a&gt;. Xcode 27 leans on MCP and agents, and this is how those pieces fit together.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.rabinarayanpatra.com/blogs/best-claude-skills-for-developers-2026" rel="noopener noreferrer"&gt;10 Best Claude Skills for Developers in 2026&lt;/a&gt;. Practical agent workflows that map onto Xcode 27's new agentic coding.&lt;/li&gt;
&lt;/ul&gt;

</description>
    </item>
    <item>
      <title>How to Migrate Vercel AI SDK 5 to 6: Breaking Changes Guide</title>
      <dc:creator>Rabinarayan Patra</dc:creator>
      <pubDate>Tue, 16 Jun 2026 18:30:00 +0000</pubDate>
      <link>https://dev.to/rabinarayanpatra/how-to-migrate-vercel-ai-sdk-5-to-6-breaking-changes-guide-mcf</link>
      <guid>https://dev.to/rabinarayanpatra/how-to-migrate-vercel-ai-sdk-5-to-6-breaking-changes-guide-mcf</guid>
      <description>&lt;p&gt;Vercel shipped AI SDK 6 on May 7, 2026. It is the biggest breaking-change release the SDK has shipped since the 3.0 split. The v3 Language Model Specification, a real Agent abstraction, human-in-the-loop tool approval, a unified message-parts model on useChat, and a new streaming wire format all landed in the same drop.&lt;/p&gt;

&lt;p&gt;I migrated the AI chatbot on this portfolio over the weekend. The codemod did most of the mechanical work, but four spots needed hand edits. This post walks the migration the way I ran it, with the exact code diffs that broke and the verification steps that confirmed the migration shipped without a silent UX regression.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed in Vercel AI SDK 6?
&lt;/h2&gt;

&lt;p&gt;AI SDK 6 ships the v3 Language Model Specification, the Agent interface with ToolLoopAgent as the production implementation, a unified message-parts array on useChat, an OAuth-ready MCP client, reranking, image editing, and a &lt;a href="https://www.rabinarayanpatra.com/snippets/nextjs/streaming-suspense-loading" rel="noopener noreferrer"&gt;new streaming wire format that pairs naturally with Suspense and &lt;code&gt;loading.tsx&lt;/code&gt;&lt;/a&gt; for a 15 to 25 percent first-token latency win on cold-start route invocations. The Vercel post calls the wire format change "the foundation for several capabilities Vercel has been signposting for the second half of 2026".&lt;/p&gt;

&lt;p&gt;The four breaking-change axes that affect existing apps are:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Axis&lt;/th&gt;
&lt;th&gt;What broke&lt;/th&gt;
&lt;th&gt;Where you fix it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;useChat message-parts&lt;/td&gt;
&lt;td&gt;Messages no longer carry a flat &lt;code&gt;content&lt;/code&gt; string. They carry a &lt;code&gt;parts&lt;/code&gt; array of typed entries.&lt;/td&gt;
&lt;td&gt;Every React component that renders &lt;code&gt;message.content&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tool-call streaming lifecycle&lt;/td&gt;
&lt;td&gt;Tool calls now flow through explicit &lt;code&gt;state&lt;/code&gt; transitions (&lt;code&gt;input-streaming&lt;/code&gt;, &lt;code&gt;input-available&lt;/code&gt;, &lt;code&gt;output-available&lt;/code&gt;, &lt;code&gt;approval-requested&lt;/code&gt;).&lt;/td&gt;
&lt;td&gt;Tool render switch statements in your UI&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Provider-adapter contract&lt;/td&gt;
&lt;td&gt;The v3 Language Model Specification changes how provider packages expose tools, finish reasons, and usage details.&lt;/td&gt;
&lt;td&gt;Custom provider wrappers and middleware&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Streaming wire format&lt;/td&gt;
&lt;td&gt;New ordering and chunk types for tool calls and reasoning traces.&lt;/td&gt;
&lt;td&gt;Anything that reads the SSE stream directly&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The codemod handles the easy half. The other half is where this guide focuses.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which breaking changes hit you first?
&lt;/h2&gt;

&lt;p&gt;The useChat parts model is the change that hits you first because it is silent. Your app keeps compiling. Your messages array keeps populating. But the moment a tool gets invoked, the tool-call UI never renders, because your code is still reading &lt;code&gt;message.content&lt;/code&gt; and the tool call now lives in &lt;code&gt;message.parts&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I caught this in my own portfolio because the AI chat that answers questions about my blog posts uses one tool (&lt;code&gt;search_blog_posts&lt;/code&gt;). After upgrading, the chat worked perfectly for plain prompts and dropped the search-result card entirely for tool prompts. No console error, no broken request. Just a missing block in the UI.&lt;/p&gt;

&lt;p&gt;The second-hardest break is &lt;code&gt;streamText&lt;/code&gt; callers that built their own agent loop with &lt;code&gt;stopWhen&lt;/code&gt; and a step counter. The v6 way is &lt;code&gt;ToolLoopAgent&lt;/code&gt;, and the two cannot be mixed in a single component because they emit different stream shapes.&lt;/p&gt;

&lt;p&gt;The third break is the wire format. If you parse the SSE stream by hand anywhere in your codebase, you have to rewrite that parser. Most apps do not, but server-rendered chat UIs and custom telemetry layers usually do.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you run the codemod the right way?
&lt;/h2&gt;

&lt;p&gt;You run the codemod by pinning the SDK version first, running the official transform from a clean working tree, then reviewing every changed file before you let it land. Treat the codemod output as a starting point, not a finished migration.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. Pin both packages at the v6 line. Do this before running the codemod.&lt;/span&gt;
npm &lt;span class="nb"&gt;install &lt;/span&gt;ai@6 @ai-sdk/openai@6 @ai-sdk/anthropic@6

&lt;span class="c"&gt;# 2. Confirm a clean git status. The codemod will rewrite files in place.&lt;/span&gt;
git status

&lt;span class="c"&gt;# 3. Run the official codemod from the repo root.&lt;/span&gt;
npx @ai-sdk/codemod upgrade v6

&lt;span class="c"&gt;# 4. Inspect the diff.&lt;/span&gt;
git diff &lt;span class="nt"&gt;--stat&lt;/span&gt;
git diff src/app
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The codemod prints a summary of files touched. On the chatbot in this portfolio it rewrote 9 files: the route handler, the chat component, and seven smaller utilities. It did not touch my custom tool renderer or the route that streams blog embedding search results, because both rely on shapes that the codemod cannot infer.&lt;/p&gt;

&lt;p&gt;If the diff looks plausible, commit it as a separate commit before touching anything else. That gives you a clean rollback point.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git add &lt;span class="nt"&gt;-A&lt;/span&gt;
git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"chore(ai-sdk): apply v6 codemod automated changes"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now you can start the hand edits on top of a known-good base.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you migrate useChat to message parts?
&lt;/h2&gt;

&lt;p&gt;You migrate useChat by rewriting every render path that reads &lt;code&gt;message.content&lt;/code&gt; to walk &lt;code&gt;message.parts&lt;/code&gt; and switch on &lt;code&gt;part.type&lt;/code&gt;. The new model treats text, tool calls, tool results, and reasoning traces as first-class entries that all live in the same array.&lt;/p&gt;

&lt;p&gt;Here is the before and after on the simplest possible chat component.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;v5 style (broken in v6 the moment a tool fires):&lt;/strong&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;use client&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;useChat&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;ai/react&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;Chat&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;messages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;handleSubmit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;handleInputChange&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useChat&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;div&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;messages&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;m&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;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&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;strong&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;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;role&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;strong&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;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&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;))&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;form&lt;/span&gt; &lt;span class="na"&gt;onSubmit&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;handleSubmit&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;input&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;onChange&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;handleInputChange&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;form&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;v6 style with the parts model:&lt;/strong&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;use client&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;useState&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;react&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;useChat&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;ai/react&lt;/span&gt;&lt;span class="dl"&gt;'&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;WeatherAgentUIMessage&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;@/agents/weather-agent&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;Chat&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;messages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sendMessage&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useChat&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;WeatherAgentUIMessage&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;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setInput&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;onSubmit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&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;FormEvent&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;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;preventDefault&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;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="nf"&gt;sendMessage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="nf"&gt;setInput&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="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;div&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;messages&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;m&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;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&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;strong&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;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;role&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;strong&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;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;parts&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;part&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&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;switch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;part&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
              &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text&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="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;i&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;part&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&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;span&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
              &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tool-weather&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="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;WeatherCard&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;invocation&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;part&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
              &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;reasoning&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="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Reasoning&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;trace&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;part&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
              &lt;span class="na"&gt;default&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="p"&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;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="si"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;form&lt;/span&gt; &lt;span class="na"&gt;onSubmit&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;onSubmit&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;input&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;onChange&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="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setInput&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&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;form&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things make this work. First, &lt;code&gt;useChat&amp;lt;WeatherAgentUIMessage&amp;gt;()&lt;/code&gt; infers the typed part union from your agent definition. The exported &lt;code&gt;InferAgentUIMessage&amp;lt;typeof weatherAgent&amp;gt;&lt;/code&gt; is what you import as &lt;code&gt;WeatherAgentUIMessage&lt;/code&gt;. Second, &lt;code&gt;handleSubmit&lt;/code&gt; and &lt;code&gt;handleInputChange&lt;/code&gt; are gone in v6. You manage the input state yourself and call &lt;code&gt;sendMessage({ text })&lt;/code&gt; to submit. Third, you need a &lt;code&gt;default&lt;/code&gt; fallback in the switch. If you forget it and the agent introduces a new part type, the UI silently drops that block at runtime.&lt;/p&gt;

&lt;p&gt;After this change, both the plain-text path and the typed &lt;code&gt;tool-weather&lt;/code&gt; path render. The Vercel migration playbook calls this "the silent UX regression" and recommends adding a unit test that asserts every part type your agent emits has a render case.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you replace streamText with ToolLoopAgent?
&lt;/h2&gt;

&lt;p&gt;You replace streamText by extracting the loop configuration into a &lt;code&gt;ToolLoopAgent&lt;/code&gt; instance and calling &lt;code&gt;.stream()&lt;/code&gt; or &lt;code&gt;.generate()&lt;/code&gt; on it. The agent holds the model, instructions, tools, and stop condition once, and you reuse it everywhere from chat routes to background jobs to standalone scripts.&lt;/p&gt;

&lt;p&gt;The before and after on a typical route handler looks like this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;v5 route handler:&lt;/strong&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/chat/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;streamText&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;ai&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;openai&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;@ai-sdk/openai&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;weatherTool&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;@/tools/weather&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;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;messages&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;result&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;streamText&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;openai&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;gpt-4o&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;system&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;You are a helpful weather assistant.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;weather&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;weatherTool&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;maxSteps&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;span class="p"&gt;})&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toDataStreamResponse&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;strong&gt;v6 route handler with ToolLoopAgent:&lt;/strong&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;// agents/weather-agent.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;ToolLoopAgent&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;InferAgentUIMessage&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;ai&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;weatherTool&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;@/tools/weather&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;weatherAgent&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;ToolLoopAgent&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;openai/gpt-5.4&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;instructions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;You are a helpful weather assistant.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;weather&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;weatherTool&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="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;WeatherAgentUIMessage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;InferAgentUIMessage&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;weatherAgent&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;

&lt;span class="c1"&gt;// app/api/chat/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;weatherAgent&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;@/agents/weather-agent&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;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;messages&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;result&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;weatherAgent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;messages&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;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toUIMessageStreamResponse&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;Three details matter on this migration. First, the plain provider-prefixed model string &lt;code&gt;'openai/gpt-5.4'&lt;/code&gt; routes through the Vercel AI Gateway by default, which is the pattern the Vercel knowledge update calls out as the recommended path. The gateway gives you per-request usage tracking, failover across providers, and zero data retention. The v5-era pattern of importing the provider package and calling &lt;code&gt;openai('...')&lt;/code&gt; still works, but the string form is shorter and is what new code should use.&lt;/p&gt;

&lt;p&gt;Second, &lt;code&gt;toUIMessageStreamResponse&lt;/code&gt; is the v6 name for what used to be &lt;code&gt;toDataStreamResponse&lt;/code&gt;. The codemod renames it most of the time. Check the route handlers.&lt;/p&gt;

&lt;p&gt;Third, &lt;code&gt;stopWhen&lt;/code&gt; lives on the agent now, not on the call. The default is 20 steps. If you used to pass &lt;code&gt;maxSteps: 5&lt;/code&gt; everywhere, set &lt;code&gt;stopWhen: stepCountIs(5)&lt;/code&gt; on the agent constructor.&lt;/p&gt;

&lt;p&gt;I wired the same pattern into the &lt;a href="https://www.rabinarayanpatra.com/blogs/vercel-ai-gateway-deep-dive" rel="noopener noreferrer"&gt;Vercel AI Gateway deep dive&lt;/a&gt; on this site. The shape is identical for Claude (&lt;code&gt;'anthropic/claude-sonnet-4.5'&lt;/code&gt;) and for any other provider in the gateway catalog.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you wire human-in-the-loop tool approval?
&lt;/h2&gt;

&lt;p&gt;You wire approval by adding a &lt;code&gt;needsApproval&lt;/code&gt; predicate to the tool and rendering an &lt;code&gt;approval-requested&lt;/code&gt; state in the UI. The agent pauses, the UI shows the approval prompt, and the user accepts or rejects before the tool actually runs.&lt;/p&gt;

&lt;p&gt;The tool side looks like this.&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;// tools/run-command.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;tool&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;ai&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;z&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;zod&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;runCommand&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;tool&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;Run a shell command on the user machine&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;inputSchema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;The shell command to execute&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;needsApproval&lt;/span&gt;&lt;span class="p"&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;command&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;command&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rm &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;execute&lt;/span&gt;&lt;span class="p"&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;command&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;stdout&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;execCommand&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;command&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="nx"&gt;stdout&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;The UI side adds a render case for the new state.&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="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tool-runCommand&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;part&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;approval-requested&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;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"approval-prompt"&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;i&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;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Run: &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;code&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;part&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;command&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;code&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;p&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;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="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
            &lt;span class="nf"&gt;addToolApprovalResponse&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="nx"&gt;part&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;approval&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
              &lt;span class="na"&gt;approved&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="si"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
          Approve
        &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;&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="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
            &lt;span class="nf"&gt;addToolApprovalResponse&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="nx"&gt;part&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;approval&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
              &lt;span class="na"&gt;approved&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="si"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
          Reject
        &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;&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;part&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;output-available&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;pre&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;i&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;part&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;output&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stdout&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;pre&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;addToolApprovalResponse&lt;/code&gt; ships from the &lt;code&gt;useChat&lt;/code&gt; return value. The agent resumes execution as soon as the approval message hits the stream. If the user rejects, the agent gets a structured rejection event and can recover or apologize.&lt;/p&gt;

&lt;p&gt;The predicate runs on the server, so it can hit a policy service or an authorization check. That is the path I use to gate shell-style tools behind a per-account permission check. The same pattern would gate the more dangerous Anthropic and OpenAI provider tools (&lt;code&gt;anthropic.tools.memory_20250818&lt;/code&gt;, &lt;code&gt;openai.tools.shell&lt;/code&gt;) when those land in your stack.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you verify the migration before shipping?
&lt;/h2&gt;

&lt;p&gt;You verify the migration by running the test suite, hitting every route that exercises tools, watching the DevTools timeline on a streaming chat, and replaying a representative production transcript against the new agent in a staging environment. If any of those four steps surfaces a regression, do not ship.&lt;/p&gt;

&lt;p&gt;For the unit-test side, I added one test per agent that walks every expected part type.&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;// __tests__ /weather-agent.test.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;describe&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;it&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;expect&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;vitest&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;weatherAgent&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;@/agents/weather-agent&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="nf"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;weatherAgent&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;emits text, tool-weather, and finish parts in order&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&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;weatherAgent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;What is the weather in San Francisco?&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;partTypes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;messages&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flatMap&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;m&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;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;)&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="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;partTypes&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toContain&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&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;partTypes&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toContain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tool-weather&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;For the DevTools verification, wrap the model in middleware and launch the viewer locally.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;import &lt;span class="o"&gt;{&lt;/span&gt; wrapLanguageModel &lt;span class="o"&gt;}&lt;/span&gt; from &lt;span class="s1"&gt;'ai'&lt;/span&gt;
import &lt;span class="o"&gt;{&lt;/span&gt; devToolsMiddleware &lt;span class="o"&gt;}&lt;/span&gt; from &lt;span class="s1"&gt;'@ai-sdk/devtools'&lt;/span&gt;
import &lt;span class="o"&gt;{&lt;/span&gt; gateway &lt;span class="o"&gt;}&lt;/span&gt; from &lt;span class="s1"&gt;'ai'&lt;/span&gt;

const tracedModel &lt;span class="o"&gt;=&lt;/span&gt; wrapLanguageModel&lt;span class="o"&gt;({&lt;/span&gt;
  model: gateway&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'openai/gpt-5.4'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;,
  middleware: devToolsMiddleware&lt;span class="o"&gt;()&lt;/span&gt;,
&lt;span class="o"&gt;})&lt;/span&gt;

npx @ai-sdk/devtools
&lt;span class="c"&gt;# http://localhost:4983&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The DevTools viewer renders the full agent timeline: prompt, tool calls, tool results, model responses, and step boundaries. Use it once per migrated route. If a tool call shows up but the matching &lt;code&gt;tool-*&lt;/code&gt; render case never fires in your UI, the parts switch is missing a case.&lt;/p&gt;

&lt;p&gt;For staging replay, capture three representative production transcripts in your logs, run them through the new agent, and diff the response shape. The token usage breakdown is the cleanest signal because the v6 &lt;code&gt;usage.inputTokenDetails&lt;/code&gt; and &lt;code&gt;usage.outputTokenDetails&lt;/code&gt; are richer than v5.&lt;/p&gt;

&lt;p&gt;If you write your own tools, this is the moment to add &lt;code&gt;inputExamples&lt;/code&gt; and &lt;code&gt;strict: true&lt;/code&gt; everywhere. They are cheap on the wire and they catch the dumbest tool-call regressions before they reach prod.&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="nf"&gt;tool&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;Search blog posts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;inputSchema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="na"&gt;strict&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;inputExamples&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;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;virtual threads&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="na"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;mcp protocol&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="na"&gt;execute&lt;/span&gt;&lt;span class="p"&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;query&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;searchBlogPosts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;query&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 deeper background on AI SDK patterns that the v6 Agent abstraction now formalizes, see the &lt;a href="https://www.rabinarayanpatra.com/blogs/vercel-ai-gateway-deep-dive" rel="noopener noreferrer"&gt;Vercel AI Gateway deep dive&lt;/a&gt; and the &lt;a href="https://www.rabinarayanpatra.com/blogs/claude-opus-4-7-release-and-migration-guide" rel="noopener noreferrer"&gt;Claude Opus 4.7 release and migration guide&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;For the original sources, see the &lt;a href="https://vercel.com/blog/ai-sdk-6" rel="noopener noreferrer"&gt;AI SDK 6 announcement&lt;/a&gt;, the &lt;a href="https://ai-sdk.dev/docs/migration-guides/migration-guide-6-0" rel="noopener noreferrer"&gt;official migration guide&lt;/a&gt;, the &lt;a href="https://ai-sdk.dev/docs/reference/ai-sdk-core/tool-loop-agent" rel="noopener noreferrer"&gt;ToolLoopAgent reference&lt;/a&gt;, and the &lt;a href="https://www.digitalapplied.com/blog/vercel-ai-sdk-v5-to-v6-migration-playbook-2026" rel="noopener noreferrer"&gt;Vercel AI SDK v5 to v6 migration playbook&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Keep Reading
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://www.rabinarayanpatra.com/blogs/vercel-ai-gateway-deep-dive" rel="noopener noreferrer"&gt;Vercel AI Gateway Deep Dive&lt;/a&gt;. The provider routing layer that the v6 agent string format relies on, with cost and observability patterns.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.rabinarayanpatra.com/blogs/claude-opus-4-7-release-and-migration-guide" rel="noopener noreferrer"&gt;Claude Opus 4.7 Release and Migration Guide&lt;/a&gt;. Provider-side context for the Anthropic models you plug into ToolLoopAgent.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.rabinarayanpatra.com/blogs/spring-ai-2-mcp-annotations-tutorial" rel="noopener noreferrer"&gt;Spring AI 2.0 MCP Annotations Tutorial&lt;/a&gt;. The same tool-loop pattern from the JVM side, and how the v6 MCP client connects both ends.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.rabinarayanpatra.com/blogs/replacing-useeffect-data-fetching-server-actions" rel="noopener noreferrer"&gt;Replacing useEffect with Server Actions&lt;/a&gt;. The Next.js data-fetching shift that pairs with the v6 useChat parts model in App Router chat UIs.&lt;/li&gt;
&lt;/ul&gt;

</description>
    </item>
    <item>
      <title>10 Best Claude Skills for Developers in 2026</title>
      <dc:creator>Rabinarayan Patra</dc:creator>
      <pubDate>Tue, 09 Jun 2026 18:30:00 +0000</pubDate>
      <link>https://dev.to/rabinarayanpatra/10-best-claude-skills-for-developers-in-2026-57a3</link>
      <guid>https://dev.to/rabinarayanpatra/10-best-claude-skills-for-developers-in-2026-57a3</guid>
      <description>&lt;p&gt;Anthropic shipped Skills in October 2025. A year and a half later, the skill ecosystem is real. &lt;a href="https://github.com/anthropics/skills" rel="noopener noreferrer"&gt;anthropics/skills&lt;/a&gt; has 141,849 stars. &lt;a href="https://github.com/obra/superpowers" rel="noopener noreferrer"&gt;obra/superpowers&lt;/a&gt; has 209,283 stars. The &lt;a href="https://agentskills.io" rel="noopener noreferrer"&gt;open standard&lt;/a&gt; is live.&lt;/p&gt;

&lt;p&gt;I install a lot of skills. Most do not earn the slot. These ten do. Quick-look format below: name, direct link, what it does, what I use it for. Ranked by impact in real work, with parent-repo star count as a popularity tiebreaker.&lt;/p&gt;

&lt;h2&gt;
  
  
  What are Claude Skills?
&lt;/h2&gt;

&lt;p&gt;A Claude Skill is a folder with a &lt;code&gt;SKILL.md&lt;/code&gt; file that teaches Claude one specific task. When the model sees a task that matches, it loads the skill and follows the playbook inside. The point is making behavior reliable. You write the convention once; every future session follows it.&lt;/p&gt;

&lt;p&gt;Skills are generally available across claude.ai, Claude Code, and the Claude API (the API still requires the &lt;code&gt;skills-2025-10-02&lt;/code&gt; beta header).&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you install Claude Skills?
&lt;/h2&gt;

&lt;p&gt;Three install paths cover everything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Path 1: Anthropic official skills (clone into your skills folder)&lt;/span&gt;
&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; ~/.claude/skills
&lt;span class="nb"&gt;cd&lt;/span&gt; ~/.claude/skills
git clone &lt;span class="nt"&gt;--depth&lt;/span&gt; 1 https://github.com/anthropics/skills.git anthropic-skills

&lt;span class="c"&gt;# Path 2: superpowers plugin (run inside a Claude Code session)&lt;/span&gt;
/plugin &lt;span class="nb"&gt;install &lt;/span&gt;superpowers@claude-plugins-official

&lt;span class="c"&gt;# Path 3: per-project skills (drop into .claude/skills/ in the repo root)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After install, restart your Claude Code session once.&lt;/p&gt;

&lt;h2&gt;
  
  
  What are the 10 best Claude skills for developers?
&lt;/h2&gt;

&lt;p&gt;Ranked by impact, parent-repo stars listed for transparency.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. brainstorming
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Skill:&lt;/strong&gt; &lt;a href="https://github.com/obra/superpowers/tree/main/skills/brainstorming" rel="noopener noreferrer"&gt;obra/superpowers · brainstorming&lt;/a&gt; (parent repo 209k stars)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What it does:&lt;/strong&gt; before any feature work, runs a structured exploration of intent, requirements, and design. Writes a short design memo. Asks for approval before any code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use it for:&lt;/strong&gt; every "build X" or "add Y" request. Catches the one design question you forgot to ask.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  2. systematic-debugging
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Skill:&lt;/strong&gt; &lt;a href="https://github.com/obra/superpowers/tree/main/skills/systematic-debugging" rel="noopener noreferrer"&gt;obra/superpowers · systematic-debugging&lt;/a&gt; (209k stars)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What it does:&lt;/strong&gt; forces a four-step bug protocol: reproduce deterministically, hypothesize root cause, prove or disprove with the smallest test, fix only the root cause.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use it for:&lt;/strong&gt; every flaky test and every "why is this slow?" investigation. Replaces try-catch reflex with real root-cause work.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  3. test-driven-development
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Skill:&lt;/strong&gt; &lt;a href="https://github.com/obra/superpowers/tree/main/skills/test-driven-development" rel="noopener noreferrer"&gt;obra/superpowers · test-driven-development&lt;/a&gt; (209k stars)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What it does:&lt;/strong&gt; writes the failing test first, runs it, shows the failure, then writes the implementation. Re-runs after each pass.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use it for:&lt;/strong&gt; any new function or feature where correctness matters. Stops Claude from "verifying" by re-reading its own code.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  4. writing-plans
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Skill:&lt;/strong&gt; &lt;a href="https://github.com/obra/superpowers/tree/main/skills/writing-plans" rel="noopener noreferrer"&gt;obra/superpowers · writing-plans&lt;/a&gt; (209k stars)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What it does:&lt;/strong&gt; turns a vague request into a numbered plan with critical files identified, tradeoffs called out, and explicit checkpoints.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use it for:&lt;/strong&gt; any task that touches 3+ files or crosses a boundary (API, DB, infra). Prevents "looks done but breaks in three places."&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  5. executing-plans
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Skill:&lt;/strong&gt; &lt;a href="https://github.com/obra/superpowers/tree/main/skills/executing-plans" rel="noopener noreferrer"&gt;obra/superpowers · executing-plans&lt;/a&gt; (209k stars)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What it does:&lt;/strong&gt; runs a plan one step at a time in a separate session, with explicit checkpoints between steps.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use it for:&lt;/strong&gt; following up on a writing-plans output. Lets you resume mid-plan after a fix or a context switch.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  6. subagent-driven-development
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Skill:&lt;/strong&gt; &lt;a href="https://github.com/obra/superpowers/tree/main/skills/subagent-driven-development" rel="noopener noreferrer"&gt;obra/superpowers · subagent-driven-development&lt;/a&gt; (209k stars)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What it does:&lt;/strong&gt; spawns a subagent per independent subtask, runs them in parallel, merges results. Enforces clean handoff contracts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use it for:&lt;/strong&gt; any task with parallelizable pieces (audit v1 vs v2 API, scan 5 services, compare 3 SDKs). Saves wall time and context budget.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  7. verification-before-completion
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Skill:&lt;/strong&gt; &lt;a href="https://github.com/obra/superpowers/tree/main/skills/verification-before-completion" rel="noopener noreferrer"&gt;obra/superpowers · verification-before-completion&lt;/a&gt; (209k stars)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What it does:&lt;/strong&gt; runs the actual app or test suite end-to-end before reporting the task done. Surfaces evidence of what worked and what broke.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use it for:&lt;/strong&gt; the "tests pass but the feature is broken" class of bug. Pair with brainstorming + TDD for the full discipline loop.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  8. skill-creator
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Skill:&lt;/strong&gt; &lt;a href="https://github.com/anthropics/skills/tree/main/skills/skill-creator" rel="noopener noreferrer"&gt;anthropics/skills · skill-creator&lt;/a&gt; (parent repo 142k stars)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What it does:&lt;/strong&gt; interviews you on trigger language, steps, and references, then writes a new &lt;code&gt;SKILL.md&lt;/code&gt; in the right shape.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use it for:&lt;/strong&gt; every repeatable task you do more than twice a week. The meta-skill that pays for itself the first time.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  9. graphify
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Skill:&lt;/strong&gt; &lt;a href="https://github.com/safishamsi/graphify" rel="noopener noreferrer"&gt;safishamsi/graphify&lt;/a&gt; (55k stars)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What it does:&lt;/strong&gt; runs an AST pass over your project, identifies god nodes, maps community structure, writes a queryable wiki under &lt;code&gt;graphify-out/&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use it for:&lt;/strong&gt; any repo over 30k lines. Future sessions read the wiki instead of grep-walking the file tree.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  10. mcp-builder
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Skill:&lt;/strong&gt; &lt;a href="https://github.com/anthropics/skills/tree/main/skills/mcp-builder" rel="noopener noreferrer"&gt;anthropics/skills · mcp-builder&lt;/a&gt; (parent repo 142k stars)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What it does:&lt;/strong&gt; scaffolds an MCP server end-to-end (transport choice, tool annotations, auth, deployment hints). Generates a working Spring/Node/Python skeleton.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use it for:&lt;/strong&gt; the first MCP server you build. Replaces the "read 4 docs to get started" ritual with one prompt.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Which Claude skills should you install first?
&lt;/h2&gt;

&lt;p&gt;Install in this order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Run &lt;code&gt;/plugin install superpowers@claude-plugins-official&lt;/code&gt; in Claude Code. Pulls 7 of the 10 skills above.&lt;/li&gt;
&lt;li&gt;Clone &lt;a href="https://github.com/anthropics/skills" rel="noopener noreferrer"&gt;anthropics/skills&lt;/a&gt; into &lt;code&gt;~/.claude/skills/anthropic-skills/&lt;/code&gt;. Gives you skill-creator, mcp-builder, and a wider base library.&lt;/li&gt;
&lt;li&gt;Install &lt;a href="https://github.com/safishamsi/graphify" rel="noopener noreferrer"&gt;safishamsi/graphify&lt;/a&gt; when your project crosses 30k lines.&lt;/li&gt;
&lt;li&gt;Run skill-creator once. Build a custom skill for the most-repeated task in your current project. Writing one teaches you how to read others.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The thing nobody says out loud: the compounding only kicks in if you actually use them. Skills are habits, not features. Install one. Use it for two weeks. Then install the next.&lt;/p&gt;

&lt;p&gt;For more on Claude Skills internals, see the &lt;a href="https://www.anthropic.com/engineering/equipping-agents-for-the-real-world-with-agent-skills" rel="noopener noreferrer"&gt;Anthropic engineering deep-dive on agent skills&lt;/a&gt;, the &lt;a href="https://code.claude.com/docs/en/skills" rel="noopener noreferrer"&gt;Claude Code skills documentation&lt;/a&gt;, and the &lt;a href="https://agentskills.io" rel="noopener noreferrer"&gt;open skills standard at agentskills.io&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Keep Reading
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://www.rabinarayanpatra.com/blogs/claude-skills-vs-mcp-vs-projects" rel="noopener noreferrer"&gt;Claude Skills vs MCP vs Projects&lt;/a&gt;. Where skills end and MCP servers begin.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.rabinarayanpatra.com/blogs/claude-code-routines-ci-automation" rel="noopener noreferrer"&gt;Claude Code Routines: Async CI Automation Just Became Real&lt;/a&gt;. Use skills inside scheduled CI runs.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.rabinarayanpatra.com/blogs/claude-opus-4-7-release-and-migration-guide" rel="noopener noreferrer"&gt;Claude Opus 4.7 Release and Migration Guide&lt;/a&gt;. The model these skills run on top of.&lt;/li&gt;
&lt;/ul&gt;

</description>
    </item>
    <item>
      <title>How to Set Up Sanity Studio in Next.js 16 (Embedded Dashboard)</title>
      <dc:creator>Rabinarayan Patra</dc:creator>
      <pubDate>Thu, 04 Jun 2026 18:30:00 +0000</pubDate>
      <link>https://dev.to/rabinarayanpatra/how-to-set-up-sanity-studio-in-nextjs-16-embedded-dashboard-226c</link>
      <guid>https://dev.to/rabinarayanpatra/how-to-set-up-sanity-studio-in-nextjs-16-embedded-dashboard-226c</guid>
      <description>&lt;p&gt;The default mental model for a headless CMS is two places: the CMS dashboard lives over there on the vendor domain, and your site lives over here on Vercel. Editors bounce between tabs, you copy environment variables between systems, and somebody always forgets to allowlist a domain in CORS.&lt;/p&gt;

&lt;p&gt;Sanity does not require any of that. The Studio is a React app you ship inside your Next.js project. One catch-all route mounts the editor at &lt;code&gt;/studio&lt;/code&gt;, your schemas live in TypeScript next to the rest of your code, and the whole thing deploys on the same Vercel push that ships your blog. This post walks through the full setup for Next.js 16: install, config, a real post + author + category schema, querying from a Server Component, the CORS fix every first-time user hits, and the Vercel deploy.&lt;/p&gt;

&lt;p&gt;I built this exact integration into a small content site I run, and I will use that as the running example. If you already have a Next.js 16 app and want a working CMS dashboard inside it by the end of the next hour, you are in the right place.&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.amazonaws.com%2Fuploads%2Farticles%2Flz9iuc96t1o3vfnidqg8.webp" 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.amazonaws.com%2Fuploads%2Farticles%2Flz9iuc96t1o3vfnidqg8.webp" alt="Sanity Studio embedded in a Next.js 16 app. Left panel shows the /studio route with Post, Author, and Category document types and a Publish button. Right panel shows the /blog route rendering published posts." width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What is Sanity Studio and why embed it in Next.js?
&lt;/h2&gt;

&lt;p&gt;Sanity Studio is the editor UI that ships with Sanity, a hosted content platform that splits cleanly into two pieces: an open-source React app for editors, and a managed document store called the Content Lake. The Studio is the part you customize. The Lake is the part you query.&lt;/p&gt;

&lt;p&gt;The reason to embed it in Next.js, instead of running it on the default &lt;code&gt;sanity.studio&lt;/code&gt; subdomain, is that almost every project ends up wanting the same three things. Editors should sign in once on the same domain as the site. Schemas should live in TypeScript next to the components that render them. Deploys should be one Vercel push, not two. Embedding gets you all three for the cost of one catch-all route.&lt;/p&gt;

&lt;p&gt;The latest stable release is &lt;code&gt;sanity@5.30.0&lt;/code&gt;, with the official Next.js toolkit at &lt;code&gt;next-sanity@13.0.11&lt;/code&gt; as of June 2026. Both target the App Router and React 19, and Sanity migrated its own docs platform to Next.js 16 earlier this year, so the integration path is well-trodden.&lt;/p&gt;

&lt;h2&gt;
  
  
  What do you need before installing Sanity in Next.js 16?
&lt;/h2&gt;

&lt;p&gt;You need a working Next.js 16 App Router project on Node 20 or newer, plus a free Sanity account. That is the entire prerequisite list.&lt;/p&gt;

&lt;p&gt;Concretely:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Node 20+.&lt;/strong&gt; The current Studio package drops support for Node 18 and 19. Check with &lt;code&gt;node -v&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A Next.js 16 app on the App Router.&lt;/strong&gt; Pages Router works too with a different route file, but this guide assumes App Router because that is what Next.js 16 starters default to.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A free Sanity account at &lt;code&gt;sanity.io&lt;/code&gt;.&lt;/strong&gt; The free tier gives you a project, a dataset, three editor seats, and 10k API requests per month, which is enough for a personal site.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A package manager.&lt;/strong&gt; I use &lt;code&gt;npm&lt;/code&gt;, but &lt;code&gt;pnpm&lt;/code&gt; and &lt;code&gt;yarn&lt;/code&gt; work without changes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you are starting from scratch, the rest of this guide assumes the same shape as a typical Next.js 16 app: a &lt;code&gt;src/app&lt;/code&gt; directory, TypeScript on, Tailwind optional. The same steps apply if you keep &lt;code&gt;app&lt;/code&gt; at the repo root.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you scaffold Sanity Studio inside an existing Next.js app?
&lt;/h2&gt;

&lt;p&gt;Run &lt;code&gt;npx sanity@latest init&lt;/code&gt; at the root of your Next.js project, then install the Next.js bridge package separately. The init command creates the Sanity project on the server, writes a starter &lt;code&gt;sanity.config.ts&lt;/code&gt;, and adds a &lt;code&gt;sanity/&lt;/code&gt; directory with example schemas.&lt;/p&gt;

&lt;p&gt;The full sequence from a clean Next.js 16 repo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. Provision the project and write starter config&lt;/span&gt;
npx sanity@latest init

&lt;span class="c"&gt;# 2. Install the Next.js bridge and the image URL helper&lt;/span&gt;
npm &lt;span class="nb"&gt;install &lt;/span&gt;next-sanity @sanity/image-url
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When &lt;code&gt;sanity init&lt;/code&gt; runs, it walks you through a short CLI prompt:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Sign in with email, GitHub, or Google (browser tab opens).&lt;/li&gt;
&lt;li&gt;Pick "Create new project" and give it a name.&lt;/li&gt;
&lt;li&gt;Pick &lt;code&gt;production&lt;/code&gt; as the default dataset.&lt;/li&gt;
&lt;li&gt;Choose "Yes" when it asks to add the example schema (you will replace it shortly).&lt;/li&gt;
&lt;li&gt;Pick TypeScript and the &lt;code&gt;npm&lt;/code&gt; package manager when prompted.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The CLI prints two values at the end: a project ID (an eight-character string) and a dataset name (&lt;code&gt;production&lt;/code&gt;). Drop them into &lt;code&gt;.env.local&lt;/code&gt; exactly as they are:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="c"&gt;# .env.local
&lt;/span&gt;&lt;span class="py"&gt;NEXT_PUBLIC_SANITY_PROJECT_ID&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;your_project_id&lt;/span&gt;
&lt;span class="py"&gt;NEXT_PUBLIC_SANITY_DATASET&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;production&lt;/span&gt;
&lt;span class="py"&gt;SANITY_API_READ_TOKEN&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;optional_for_drafts&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; prefix is required. The Studio runs in the browser and needs to read both values from &lt;code&gt;process.env&lt;/code&gt; at build time. The third variable is only needed later if you want the public site to render unpublished drafts for previews.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you wire the embedded Studio route at /studio?
&lt;/h2&gt;

&lt;p&gt;Create two files: &lt;code&gt;sanity.config.ts&lt;/code&gt; at the project root, and a catch-all route at &lt;code&gt;app/studio/[[...tool]]/page.tsx&lt;/code&gt;. The route imports the config and hands it to the &lt;code&gt;NextStudio&lt;/code&gt; component, which does the actual rendering.&lt;/p&gt;

&lt;p&gt;The config file is where you declare the project ID, dataset, base path, plugins, and your schema list. A minimal version for a blog looks like this:&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;// sanity.config.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;defineConfig&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;sanity&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;structureTool&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;sanity/structure&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;visionTool&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;@sanity/vision&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;schemaTypes&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;./sanity/schemaTypes&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="nf"&gt;defineConfig&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;default&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;My Blog Studio&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;projectId&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;NEXT_PUBLIC_SANITY_PROJECT_ID&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;dataset&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;NEXT_PUBLIC_SANITY_DATASET&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;basePath&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/studio&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;structureTool&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nf"&gt;visionTool&lt;/span&gt;&lt;span class="p"&gt;()],&lt;/span&gt;
  &lt;span class="na"&gt;schema&lt;/span&gt;&lt;span class="p"&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="nx"&gt;schemaTypes&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;Two things matter here. &lt;code&gt;basePath: '/studio'&lt;/code&gt; tells the Studio where it is mounted, so internal links point at &lt;code&gt;/studio/structure/post&lt;/code&gt; and not the root. &lt;code&gt;plugins&lt;/code&gt; always includes &lt;code&gt;structureTool()&lt;/code&gt; (the document list and editor) and, for development, &lt;code&gt;visionTool()&lt;/code&gt; (a GROQ query playground).&lt;/p&gt;

&lt;p&gt;The route file is short. It imports the config, exports the &lt;code&gt;metadata&lt;/code&gt; and &lt;code&gt;viewport&lt;/code&gt; that &lt;code&gt;next-sanity&lt;/code&gt; already prepared, and renders the Studio:&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/studio/[[...tool]]/page.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;NextStudio&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-sanity/studio&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="nx"&gt;config&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;../../../sanity.config&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;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-static&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="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="nx"&gt;viewport&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-sanity/studio&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;StudioPage&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="nc"&gt;NextStudio&lt;/span&gt; &lt;span class="na"&gt;config&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;config&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;force-static&lt;/code&gt; makes the route itself a static shell. The Studio is a heavy client bundle and there is no benefit to re-rendering the shell on every request. The actual editor work happens client-side once the shell ships.&lt;/p&gt;

&lt;p&gt;Once these two files exist, start the dev server with &lt;code&gt;npm run dev&lt;/code&gt; and open &lt;code&gt;http://localhost:3000/studio&lt;/code&gt;. The Studio loads, asks you to sign in, and then shows the document list. Right now the list is empty because you have not defined any schema types yet.&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.amazonaws.com%2Fuploads%2Farticles%2Fhcerra338a5nuwpenkje.webp" 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.amazonaws.com%2Fuploads%2Farticles%2Fhcerra338a5nuwpenkje.webp" alt="Sanity Studio empty document type picker with Artist, Event, and Venue options. Image: Sanity Inc." width="800" height="529"&gt;&lt;/a&gt;&lt;em&gt;Source: &lt;a href="https://www.sanity.io/learn/course/day-one-with-sanity-studio" rel="noopener noreferrer"&gt;Sanity Learn: Day one with Sanity Studio&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The screenshot above is from the official Sanity learn course, showing the Studio chrome and the document creation menu populated by schema types. Once you add the schemas in the next section, your menu will show Post, Author, and Category in place of those examples.&lt;/p&gt;

&lt;p&gt;This three-layer architecture is the part that makes the embedded approach worthwhile. The same browser session talks to two Next.js routes that talk to the same Sanity project, with the editor on the write path and the public pages on the read path:&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.amazonaws.com%2Fuploads%2Farticles%2Fjddjg30gwg9d8s233jxx.webp" 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.amazonaws.com%2Fuploads%2Farticles%2Fjddjg30gwg9d8s233jxx.webp" alt="Three-layer architecture diagram. Top: Browser. Middle: Next.js 16 App Router with /studio mounting NextStudio for the write path and /blog using a Server Component with sanityClient.fetch for the read path. Bottom: Sanity Content Lake holding the project ID, dataset, and CORS allowlist." width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you write a real content schema (post + author + category)?
&lt;/h2&gt;

&lt;p&gt;Create a &lt;code&gt;sanity/schemaTypes/&lt;/code&gt; directory with one file per document type, then export them as an array from &lt;code&gt;sanity/schemaTypes/index.ts&lt;/code&gt;. Each file describes the shape of one document using the &lt;code&gt;defineType&lt;/code&gt; and &lt;code&gt;defineField&lt;/code&gt; helpers from the &lt;code&gt;sanity&lt;/code&gt; package.&lt;/p&gt;

&lt;p&gt;For a blog you want three documents. A &lt;code&gt;post&lt;/code&gt; holds the article. An &lt;code&gt;author&lt;/code&gt; holds the person who wrote it. A &lt;code&gt;category&lt;/code&gt; groups posts. Posts reference authors and categories, so editors fill in those fields with a dropdown instead of free text.&lt;/p&gt;

&lt;p&gt;Start with &lt;code&gt;post.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="c1"&gt;// sanity/schemaTypes/post.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;defineField&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;defineType&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;sanity&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;post&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;defineType&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;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;title&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;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;document&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nf"&gt;defineField&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;title&lt;/span&gt;&lt;span class="dl"&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;string&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;validation&lt;/span&gt;&lt;span class="p"&gt;:&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;required&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="nf"&gt;defineField&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;slug&lt;/span&gt;&lt;span class="dl"&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;slug&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;title&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;maxLength&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;96&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;validation&lt;/span&gt;&lt;span class="p"&gt;:&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;required&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="nf"&gt;defineField&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;author&lt;/span&gt;&lt;span class="dl"&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;reference&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;to&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;author&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="nf"&gt;defineField&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;category&lt;/span&gt;&lt;span class="dl"&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;reference&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;to&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;category&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="nf"&gt;defineField&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;mainImage&lt;/span&gt;&lt;span class="dl"&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;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;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;hotspot&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;fields&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;alt&lt;/span&gt;&lt;span class="dl"&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;string&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Alt text&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="nf"&gt;defineField&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;publishedAt&lt;/span&gt;&lt;span class="dl"&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;datetime&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="nf"&gt;defineField&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;body&lt;/span&gt;&lt;span class="dl"&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;blockContent&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;A few choices worth flagging. &lt;code&gt;validation: r =&amp;gt; r.required().max(80)&lt;/code&gt; runs in the Studio in real time, so editors see the red warning the moment a title is empty or too long. &lt;code&gt;options: { source: 'title' }&lt;/code&gt; on the slug field wires up the "Generate" button, which converts the title to a URL slug. &lt;code&gt;options: { hotspot: true }&lt;/code&gt; on the image field lets editors pick a focal point so Sanity can crop responsively without cutting off heads.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;author.ts&lt;/code&gt; and &lt;code&gt;category.ts&lt;/code&gt; are smaller:&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;// sanity/schemaTypes/author.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;defineField&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;defineType&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;sanity&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;author&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;defineType&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;author&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Author&lt;/span&gt;&lt;span class="dl"&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;document&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nf"&gt;defineField&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;name&lt;/span&gt;&lt;span class="dl"&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;string&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;validation&lt;/span&gt;&lt;span class="p"&gt;:&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;required&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="nf"&gt;defineField&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;slug&lt;/span&gt;&lt;span class="dl"&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;slug&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;name&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;validation&lt;/span&gt;&lt;span class="p"&gt;:&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;required&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="nf"&gt;defineField&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;avatar&lt;/span&gt;&lt;span class="dl"&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;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;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;hotspot&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="nf"&gt;defineField&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;bio&lt;/span&gt;&lt;span class="dl"&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;text&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&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="c1"&gt;// sanity/schemaTypes/category.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;defineField&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;defineType&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;sanity&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;category&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;defineType&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;category&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Category&lt;/span&gt;&lt;span class="dl"&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;document&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nf"&gt;defineField&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;title&lt;/span&gt;&lt;span class="dl"&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;string&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;validation&lt;/span&gt;&lt;span class="p"&gt;:&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;required&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="nf"&gt;defineField&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;slug&lt;/span&gt;&lt;span class="dl"&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;slug&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;title&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;validation&lt;/span&gt;&lt;span class="p"&gt;:&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;required&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="nf"&gt;defineField&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;description&lt;/span&gt;&lt;span class="dl"&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;text&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&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;The &lt;code&gt;blockContent&lt;/code&gt; type that the post body uses is the portable text format Sanity ships for rich text. It is a one-line export:&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;// sanity/schemaTypes/blockContent.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;defineType&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;sanity&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;blockContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;defineType&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;blockContent&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Block Content&lt;/span&gt;&lt;span class="dl"&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;array&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;of&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;block&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;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;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;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;hotspot&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="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The index file wires them together:&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;// sanity/schemaTypes/index.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;post&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;./post&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;author&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;./author&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;category&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;./category&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;blockContent&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;./blockContent&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;schemaTypes&lt;/span&gt; &lt;span class="o"&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;author&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;category&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;blockContent&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reload &lt;code&gt;http://localhost:3000/studio&lt;/code&gt;. The Studio picks up the new types and the document list now shows Post, Author, and Category. Create one of each. The reference fields will let you link a post to the author and category you just made.&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.amazonaws.com%2Fuploads%2Farticles%2Fbfo1vs90nl2nolyce1kn.webp" 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.amazonaws.com%2Fuploads%2Farticles%2Fbfo1vs90nl2nolyce1kn.webp" alt="Sanity Studio document editor showing the Cosmic Harmony Festival document with the Name field. Image: Sanity Inc." width="800" height="529"&gt;&lt;/a&gt;&lt;em&gt;Source: &lt;a href="https://www.sanity.io/learn/course/day-one-with-sanity-studio" rel="noopener noreferrer"&gt;Sanity Learn: Day one with Sanity Studio&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This is what the editor looks like for a real document: the list pane on the left, the document with its declared fields on the right, drafts and publish state at the top. With the schema above, your screen looks the same shape, with your post fields in place of the Name shown here.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you query Sanity content from a Server Component?
&lt;/h2&gt;

&lt;p&gt;Create a thin client wrapper at &lt;code&gt;lib/sanity/client.ts&lt;/code&gt;, then import it from any Server Component that needs to render content. The client uses GROQ, Sanity's query language, which feels like JSON Path with projections.&lt;/p&gt;

&lt;p&gt;The wrapper is short:&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/sanity/client.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;createClient&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-sanity&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;sanityClient&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createClient&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;projectId&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;NEXT_PUBLIC_SANITY_PROJECT_ID&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;dataset&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;NEXT_PUBLIC_SANITY_DATASET&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2026-06-01&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;useCdn&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;allPostsQuery&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`*[_type == "post"] | order(publishedAt desc) {
  _id,
  title,
  "slug": slug.current,
  publishedAt,
  "author": author-&amp;gt;name,
  "category": category-&amp;gt;title
}`&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 specifics. &lt;code&gt;apiVersion&lt;/code&gt; is a calendar date that pins query behavior, so a Sanity-side change does not silently alter your responses. Update it when you adopt a new feature. &lt;code&gt;useCdn: true&lt;/code&gt; reads from the public CDN, which is fast and free for published content. Set it to &lt;code&gt;false&lt;/code&gt; for draft previews where you want fresh data.&lt;/p&gt;

&lt;p&gt;GROQ itself is compact. &lt;code&gt;*[_type == "post"]&lt;/code&gt; filters every document by type. The &lt;code&gt;|&lt;/code&gt; pipe sorts. The projection block at the end picks fields and rewrites them: &lt;code&gt;"slug": slug.current&lt;/code&gt; flattens the slug object to a string, and &lt;code&gt;"author": author-&amp;gt;name&lt;/code&gt; follows the reference and pulls the author's name in one query.&lt;/p&gt;

&lt;p&gt;A Server Component that lists posts is then just:&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/page.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;sanityClient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;allPostsQuery&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/sanity/client&lt;/span&gt;&lt;span class="dl"&gt;'&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;PostRow&lt;/span&gt; &lt;span class="o"&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="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;title&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;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="nl"&gt;publishedAt&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;author&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;category&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="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;BlogIndexPage&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="nx"&gt;sanityClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fetch&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;PostRow&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;allPostsQuery&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;ul&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;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="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;li&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&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;_id&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;a&lt;/span&gt; &lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&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="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;p&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;a&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;span&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
            &lt;span class="si"&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="si"&gt;}&lt;/span&gt;
            by &lt;span class="si"&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;author&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; in &lt;span class="si"&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;category&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;span&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;li&lt;/span&gt;&lt;span class="p"&gt;&amp;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;lt;/&lt;/span&gt;&lt;span class="nt"&gt;ul&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;This is a Server Component, so the query runs at request time on Vercel, not in the browser. The response is HTML by the time it reaches the user. For static caching, swap to Cache Components with &lt;code&gt;'use cache'&lt;/code&gt; and a &lt;code&gt;cacheTag&lt;/code&gt; keyed off &lt;code&gt;post&lt;/code&gt;, and trigger revalidation from a Sanity webhook on publish.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you configure CORS so the Studio talks to your project?
&lt;/h2&gt;

&lt;p&gt;Open &lt;code&gt;manage.sanity.io&lt;/code&gt;, pick your project, go to &lt;strong&gt;API&lt;/strong&gt; , scroll to &lt;strong&gt;CORS origins&lt;/strong&gt; , and add every origin where the embedded Studio will run. Each entry needs the "Allow credentials" toggle turned on.&lt;/p&gt;

&lt;p&gt;The minimum allowlist for a Vercel-hosted Next.js app is three rows:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Origin&lt;/th&gt;
&lt;th&gt;When it matters&lt;/th&gt;
&lt;th&gt;Allow credentials&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;http://localhost:3000&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Local dev&lt;/td&gt;
&lt;td&gt;ON&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;https://www.your-domain.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Production&lt;/td&gt;
&lt;td&gt;ON&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;https://preview-*.your-domain.vercel.app&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Vercel preview URLs&lt;/td&gt;
&lt;td&gt;ON&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The reason "Allow credentials" must be ON is that the embedded Studio sends the Sanity editor session cookie on every API call. Without that toggle, the browser strips the cookie before sending the request, Sanity sees an anonymous call, and you get a blank Studio with a console error like &lt;code&gt;CORS policy: No 'Access-Control-Allow-Credentials' header is present on the requested resource&lt;/code&gt;. It is the single most common failure when first setting up the embedded Studio.&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.amazonaws.com%2Fuploads%2Farticles%2Fx75d6kqlsrr732c9zq8y.webp" 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.amazonaws.com%2Fuploads%2Farticles%2Fx75d6kqlsrr732c9zq8y.webp" alt="Sanity manage console CORS origins panel. Three rows are listed with Allow credentials toggled ON: http://localhost:3000, https://www.your-domain.com, and https://preview-*.your-domain.vercel.app. A red callout explains why Allow credentials must be on." width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Restart &lt;code&gt;npm run dev&lt;/code&gt; after adding the localhost entry. The dev server picks up the new CORS allowance on the next page load, and the Studio finishes mounting instead of stalling on the login screen.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you deploy the embedded Studio to Vercel?
&lt;/h2&gt;

&lt;p&gt;Add the two &lt;code&gt;NEXT_PUBLIC_SANITY_*&lt;/code&gt; env vars to your Vercel project, push the branch, and the embedded Studio is reachable at &lt;code&gt;your-domain.com/studio&lt;/code&gt; as soon as the deploy goes green. There is no separate Sanity build step.&lt;/p&gt;

&lt;p&gt;The full Vercel checklist:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Environment variables.&lt;/strong&gt; In the Vercel dashboard, go to your project, then &lt;strong&gt;Settings&lt;/strong&gt; , then &lt;strong&gt;Environment Variables&lt;/strong&gt;. Add &lt;code&gt;NEXT_PUBLIC_SANITY_PROJECT_ID&lt;/code&gt; and &lt;code&gt;NEXT_PUBLIC_SANITY_DATASET&lt;/code&gt; for all three environments (Production, Preview, Development). Add &lt;code&gt;SANITY_API_READ_TOKEN&lt;/code&gt; only if you wired the draft preview flow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CORS for production and preview URLs.&lt;/strong&gt; Back in &lt;code&gt;manage.sanity.io&lt;/code&gt;, make sure both &lt;code&gt;https://www.your-domain.com&lt;/code&gt; and &lt;code&gt;https://*-your-team.vercel.app&lt;/code&gt; are in the CORS allowlist with credentials on. Preview URLs change per commit, so the wildcard saves you from re-adding them on every branch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Push.&lt;/strong&gt; The next push triggers a Vercel build. The build compiles the Studio bundle as part of the Next.js build and ships it as a static route, so cold starts are not part of the editor experience.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test the editor.&lt;/strong&gt; Open &lt;code&gt;https://www.your-domain.com/studio&lt;/code&gt;, sign in, create a document, and confirm the content shows up on the public route.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;You can skip the hosted &lt;code&gt;sanity.studio&lt;/code&gt; deployment entirely. That product is useful when you want the editor on a different domain from the marketing site (for example, when the marketing site is on a different stack), but for a single Next.js app, the embedded route is fewer moving parts.&lt;/p&gt;

&lt;h2&gt;
  
  
  What are the common pitfalls in a Next.js 16 + Sanity setup?
&lt;/h2&gt;

&lt;p&gt;Most first-run problems come from four places: missing env vars at build time, missing CORS entries, the wrong &lt;code&gt;apiVersion&lt;/code&gt;, and treating the Studio as a normal Next.js route.&lt;/p&gt;

&lt;p&gt;In order of how often I have hit them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;process.env.NEXT_PUBLIC_SANITY_PROJECT_ID&lt;/code&gt; is undefined in production.&lt;/strong&gt; Vercel does not read &lt;code&gt;.env.local&lt;/code&gt;. Set the variables in the dashboard with the &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; prefix exactly, then redeploy. A redeploy is required for env changes to take effect.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CORS blank screen on preview URLs.&lt;/strong&gt; Vercel preview URLs change per branch. Add &lt;code&gt;https://*-your-team.vercel.app&lt;/code&gt; once instead of one entry per branch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stale data after publishing.&lt;/strong&gt; If you have ISR or Cache Components on the read path, publish events do not invalidate them automatically. Wire a Sanity webhook to a Next.js route that calls &lt;code&gt;revalidateTag&lt;/code&gt; or &lt;code&gt;updateTag&lt;/code&gt; on publish.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Studio is wrapped in your site layout.&lt;/strong&gt; The catch-all route under &lt;code&gt;app/studio/[[...tool]]/page.tsx&lt;/code&gt; inherits any layout above it. If your root layout adds a navbar or padding, the Studio renders inside it. Wrap the Studio route in its own segment with a layout that returns the children as-is, so the editor takes the full viewport.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you build the site on the same Next.js 16 stack I write about, you have probably already seen related setups in &lt;a href="https://www.rabinarayanpatra.com/blogs/hello-proxy-ts-nextjs-16" rel="noopener noreferrer"&gt;the proxy.ts post about routing middleware&lt;/a&gt; and &lt;a href="https://www.rabinarayanpatra.com/blogs/building-modern-docs-generator-nextjs-16" rel="noopener noreferrer"&gt;the docs generator post about content-driven pages&lt;/a&gt;. The Sanity Studio embed is the read/write half of the same problem those posts cover from the routing and rendering sides.&lt;/p&gt;

&lt;h2&gt;
  
  
  What did we just build?
&lt;/h2&gt;

&lt;p&gt;A real CMS dashboard at &lt;code&gt;/studio&lt;/code&gt; inside a Next.js 16 app, backed by a typed schema for posts, authors, and categories, queried from Server Components, with CORS configured for local and production, deployed on the same Vercel build as the public site. No extra infrastructure, no second domain, no separate auth flow. The whole thing fits in two config files, one route, and a &lt;code&gt;sanity/&lt;/code&gt; directory.&lt;/p&gt;

&lt;p&gt;The next step depends on what you ship next. If editors will write drafts you want to preview before publishing, set up the Presentation tool and a draft-mode route on the Next.js side. If you want to render rich text from the &lt;code&gt;body&lt;/code&gt; field, install &lt;code&gt;@portabletext/react&lt;/code&gt; and write a custom renderer for your design system. If you want full-text search, add &lt;code&gt;searchTool&lt;/code&gt; to the plugins list and you get a search bar in the Studio header for free.&lt;/p&gt;

&lt;p&gt;For reference, the primary docs I used while writing this are the &lt;a href="https://www.sanity.io/docs/nextjs" rel="noopener noreferrer"&gt;official Next.js integration guide&lt;/a&gt;, the &lt;a href="https://www.sanity.io/docs/studio/embedding-sanity-studio" rel="noopener noreferrer"&gt;embedding Sanity Studio doc&lt;/a&gt;, the &lt;a href="https://www.sanity.io/docs/studio/installation" rel="noopener noreferrer"&gt;Sanity Studio installation requirements&lt;/a&gt;, the &lt;a href="https://github.com/sanity-io/next-sanity" rel="noopener noreferrer"&gt;next-sanity toolkit on GitHub&lt;/a&gt;, and the &lt;a href="https://www.sanity.io/docs/changelog/06f976e4-865b-41df-a96c-3daca52640a3" rel="noopener noreferrer"&gt;Sanity platform changelog covering the Next.js 16 migration&lt;/a&gt;. The Studio screenshots in this post are from the &lt;a href="https://www.sanity.io/learn/course/day-one-with-sanity-studio" rel="noopener noreferrer"&gt;Day One with Sanity Studio learn course&lt;/a&gt; on the Sanity site.&lt;/p&gt;

&lt;h3&gt;
  
  
  Keep Reading
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.rabinarayanpatra.com/blogs/hello-proxy-ts-nextjs-16" rel="noopener noreferrer"&gt;Hello, proxy.ts in Next.js 16: middleware renamed and reframed&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.rabinarayanpatra.com/blogs/building-modern-docs-generator-nextjs-16" rel="noopener noreferrer"&gt;Building a modern docs generator in Next.js 16&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.rabinarayanpatra.com/blogs/nextjs-16-2-agents-md-next-browser" rel="noopener noreferrer"&gt;Next.js 16.2: AGENTS.md and the Next browser&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.rabinarayanpatra.com/blogs/build-mcp-app-interactive-ui" rel="noopener noreferrer"&gt;How to build an interactive MCP app with the MCP Apps SDK&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
    </item>
    <item>
      <title>How to Version REST APIs in Spring Framework 7 (Spring Boot 4)</title>
      <dc:creator>Rabinarayan Patra</dc:creator>
      <pubDate>Tue, 02 Jun 2026 18:30:00 +0000</pubDate>
      <link>https://dev.to/rabinarayanpatra/how-to-version-rest-apis-in-spring-framework-7-spring-boot-4-56jl</link>
      <guid>https://dev.to/rabinarayanpatra/how-to-version-rest-apis-in-spring-framework-7-spring-boot-4-56jl</guid>
      <description>&lt;p&gt;For years, versioning a Spring REST API meant picking your own poison. Custom interceptors, duplicate controllers, or a pile of &lt;code&gt;headers=&lt;/code&gt; conditions on every mapping. Spring Framework 7, the core of Spring Boot 4, ends that. Versioning is now a first-class feature baked into request mapping.&lt;/p&gt;

&lt;p&gt;I rebuilt a multi-version API on Spring Boot 4 last month and deleted about 300 lines of routing glue in the process. This guide is the playbook I wish I'd had: every strategy, real controller code, a &lt;code&gt;curl&lt;/code&gt; trace per approach, and the deprecation headers that tell clients when to move on.&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.amazonaws.com%2Fuploads%2Farticles%2F6mv22ga3frsyea3msxa6.webp" 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.amazonaws.com%2Fuploads%2Farticles%2F6mv22ga3frsyea3msxa6.webp" alt="Spring Framework 7 API versioning overview showing a versioned request routed to the correct controller handler" width="800" height="448"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed for API versioning in Spring Framework 7?
&lt;/h2&gt;

&lt;p&gt;Spring Framework 7 added a &lt;code&gt;version&lt;/code&gt; attribute to &lt;code&gt;@RequestMapping&lt;/code&gt; and every shortcut variant, so one path can fan out to multiple handlers by version. Before this, request mapping had no concept of a version at all. You bolted versioning on from the outside with interceptors or separate controller classes per release.&lt;/p&gt;

&lt;p&gt;The new model has three moving parts. A resolver pulls the version out of the request. A parser turns that raw string into a comparable semantic version. A strategy matches it against the &lt;code&gt;version&lt;/code&gt; declared on each mapping and picks the closest handler. All three are configurable, and the defaults cover the common cases.&lt;/p&gt;

&lt;p&gt;Here's the smallest possible example. Same path, two versions, two methods:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@RestController&lt;/span&gt;
&lt;span class="nd"&gt;@RequestMapping&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/accounts"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AccountController&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@GetMapping&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"/{id}"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;version&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"1.0"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;AccountV1&lt;/span&gt; &lt;span class="nf"&gt;getAccountV1&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;@PathVariable&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;accountService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findV1&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@GetMapping&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"/{id}"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;version&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"2.0"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;AccountV2&lt;/span&gt; &lt;span class="nf"&gt;getAccountV2&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;@PathVariable&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;accountService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findV2&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A request for version &lt;code&gt;1.0&lt;/code&gt; hits the first method. A request for &lt;code&gt;2.0&lt;/code&gt; hits the second. No &lt;code&gt;if&lt;/code&gt; checks, no manual parsing, no shared dispatcher method. The version is part of the mapping, exactly like the path and the HTTP method.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you turn on API versioning in Spring Boot 4?
&lt;/h2&gt;

&lt;p&gt;You turn on versioning by implementing &lt;code&gt;WebMvcConfigurer&lt;/code&gt; and overriding &lt;code&gt;configureApiVersioning&lt;/code&gt;, which hands you an &lt;code&gt;ApiVersionConfigurer&lt;/code&gt;. Nothing routes by version until you declare which resolver to use. The &lt;code&gt;version&lt;/code&gt; attribute on a mapping is inert without a configured strategy.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Configuration&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ApiVersioningConfig&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;WebMvcConfigurer&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@Override&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;configureApiVersioning&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ApiVersionConfigurer&lt;/span&gt; &lt;span class="n"&gt;configurer&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;configurer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;useRequestHeader&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"X-API-Version"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                  &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;addSupportedVersions&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"1.0"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"2.0"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the entire wiring for header-based versioning. &lt;code&gt;useRequestHeader&lt;/code&gt; names the header to read. &lt;code&gt;addSupportedVersions&lt;/code&gt; declares the versions you accept, which lets Spring reject anything unknown with a clean 400 instead of a confusing 404.&lt;/p&gt;

&lt;p&gt;If you'd rather stay in properties, Spring Boot 4 exposes the same switch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;spring.mvc.apiversion.use.header&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;X-API-Version&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;By default a version is required. A request with no version triggers &lt;code&gt;MissingApiVersionException&lt;/code&gt; and a 400 response. An unsupported version triggers &lt;code&gt;InvalidApiVersionException&lt;/code&gt;, also a 400. You can relax both, and I'll cover that under pitfalls, because the default trips up first-time users.&lt;/p&gt;

&lt;h2&gt;
  
  
  How does the version attribute route requests?
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;version&lt;/code&gt; attribute routes a request by comparing the resolved request version against the version declared on each candidate mapping, then choosing the best match. Spring parses both sides with &lt;code&gt;SemanticApiVersionParser&lt;/code&gt;, which reads &lt;code&gt;major.minor.patch&lt;/code&gt; and fills missing parts with zero. So &lt;code&gt;"1"&lt;/code&gt; becomes &lt;code&gt;1.0.0&lt;/code&gt; and &lt;code&gt;"1.2"&lt;/code&gt; becomes &lt;code&gt;1.2.0&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Matching is not a naive string equality. If a request asks for &lt;code&gt;1.5&lt;/code&gt; and your handlers declare &lt;code&gt;1.0&lt;/code&gt; and &lt;code&gt;2.0&lt;/code&gt;, the request resolves to the highest version less than or equal to &lt;code&gt;1.5&lt;/code&gt;, which is &lt;code&gt;1.0&lt;/code&gt;. That single rule is what makes baseline versioning work, and it's why you don't need a handler for every point release.&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.amazonaws.com%2Fuploads%2Farticles%2Fc9j9lqbd259wxsfvymv7.webp" 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.amazonaws.com%2Fuploads%2Farticles%2Fc9j9lqbd259wxsfvymv7.webp" alt="Diagram of the Spring Framework 7 version resolution flow from request to resolver to parser to matched handler" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The flow is the same no matter which strategy you choose. Only the first box, the resolver, changes. Pick where the version lives in the request and the rest of the pipeline stays identical.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which versioning strategy should you pick?
&lt;/h2&gt;

&lt;p&gt;Spring Framework 7 ships four resolver strategies, and you choose one with a single method on &lt;code&gt;ApiVersionConfigurer&lt;/code&gt;. Each reads the version from a different place in the request. The handler code never changes. Only the config line and the way clients call you differ.&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.amazonaws.com%2Fuploads%2Farticles%2F8mlgyofwmd332577hjbk.webp" 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.amazonaws.com%2Fuploads%2Farticles%2F8mlgyofwmd332577hjbk.webp" alt="Comparison table of the four Spring Framework 7 API versioning strategies: path segment, request header, query parameter, and media type" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Request header
&lt;/h3&gt;

&lt;p&gt;Header versioning keeps the URL clean and puts the version in a custom header. This is my default for service-to-service APIs.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;configurer.useRequestHeader("X-API-Version")
&lt;/span&gt;&lt;span class="gp"&gt;          .addSupportedVersions("1.0", "2.0");&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="go"&gt;
curl -H "X-API-Version: 2.0" http://localhost:8080/accounts/42
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Path segment
&lt;/h3&gt;

&lt;p&gt;Path-segment versioning puts the version directly in the URL, which makes it visible, bookmarkable, and easy to route at a proxy. You declare which segment holds the version by index and add a URI variable for it in the mapping.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="n"&gt;configurer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;usePathSegment&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
          &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;addSupportedVersions&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"1.0"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"2.0"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

&lt;span class="nd"&gt;@GetMapping&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"/api/{version}/accounts/{id}"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;version&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"2.0"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;AccountV2&lt;/span&gt; &lt;span class="nf"&gt;getAccount&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;@PathVariable&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;accountService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findV2&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;curl&lt;/span&gt; &lt;span class="nl"&gt;http:&lt;/span&gt;&lt;span class="c1"&gt;//localhost:8080/api/2.0/accounts/42&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Segment index is zero-based against the path. In &lt;code&gt;/api/2.0/accounts/42&lt;/code&gt; the segments are &lt;code&gt;api&lt;/code&gt;, &lt;code&gt;2.0&lt;/code&gt;, &lt;code&gt;accounts&lt;/code&gt;, &lt;code&gt;42&lt;/code&gt;, so the version sits at index &lt;code&gt;1&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Query parameter
&lt;/h3&gt;

&lt;p&gt;Query-parameter versioning reads the version from the query string. It's trivial to test in a browser and trivial to forget in a cache key, so weigh that tradeoff.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="n"&gt;configurer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;useQueryParam&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"version"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
          &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;addSupportedVersions&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"1.0"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"2.0"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;curl&lt;/span&gt; &lt;span class="s"&gt;"http://localhost:8080/accounts/42?version=2.0"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Media type
&lt;/h3&gt;

&lt;p&gt;Media-type versioning reads a parameter off the &lt;code&gt;Accept&lt;/code&gt; header, which is the most REST-purist option and the hardest for casual clients to send.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="n"&gt;configurer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;useMediaTypeParameter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;MediaType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;APPLICATION_JSON&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"version"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
          &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;addSupportedVersions&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"1.0"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"2.0"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;curl&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="no"&gt;H&lt;/span&gt; &lt;span class="s"&gt;"Accept: application/json;version=2.0"&lt;/span&gt; &lt;span class="nl"&gt;http:&lt;/span&gt;&lt;span class="c1"&gt;//localhost:8080/accounts/42&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My rule of thumb: header for internal APIs, path segment for public APIs where humans read the URLs, and skip query and media type unless you have a specific reason. The worst choice is no choice, where different endpoints use different strategies and clients can't predict which one applies.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's the difference between fixed and baseline versions?
&lt;/h2&gt;

&lt;p&gt;A fixed version matches one exact version, while a baseline version matches that version and everything above it until a higher handler takes over. You write a fixed version as &lt;code&gt;"1.2"&lt;/code&gt; and a baseline version with a trailing plus, &lt;code&gt;"1.2+"&lt;/code&gt;. The difference is how many releases a single handler is responsible for.&lt;/p&gt;

&lt;p&gt;Picture an endpoint that hasn't changed since &lt;code&gt;1.0&lt;/code&gt; and another that got a new shape in &lt;code&gt;2.0&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@RestController&lt;/span&gt;
&lt;span class="nd"&gt;@RequestMapping&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/accounts"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AccountController&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@GetMapping&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"/{id}"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;version&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"1.0+"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;AccountV1&lt;/span&gt; &lt;span class="nf"&gt;getAccount&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;@PathVariable&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;accountService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findV1&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@PostMapping&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;version&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"2.0+"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;AccountV2&lt;/span&gt; &lt;span class="nf"&gt;createAccount&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;@RequestBody&lt;/span&gt; &lt;span class="nc"&gt;CreateAccount&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;accountService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;createV2&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;GET&lt;/code&gt; handler answers &lt;code&gt;1.0&lt;/code&gt;, &lt;code&gt;1.5&lt;/code&gt;, &lt;code&gt;1.9&lt;/code&gt;, and anything up to the next declared version. The &lt;code&gt;POST&lt;/code&gt; handler owns &lt;code&gt;2.0&lt;/code&gt; and up. You only write a new method when the contract actually changes, not on every version bump. That's the whole point of baseline matching, and it's why a real API with ten releases might have three or four handlers per route instead of ten.&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.amazonaws.com%2Fuploads%2Farticles%2F6fzrci2b027647pfsiz9.webp" 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.amazonaws.com%2Fuploads%2Farticles%2F6fzrci2b027647pfsiz9.webp" alt="Diagram contrasting a fixed version that matches one release against a baseline version that matches a range of releases" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Fixed versions still matter. Use them when a single release introduced a breaking change you want pinned to one exact handler, so a typo in a client's version string fails loudly instead of silently falling through to an older shape.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you deprecate an API version with Sunset headers?
&lt;/h2&gt;

&lt;p&gt;You deprecate a version by registering a &lt;code&gt;StandardApiVersionDeprecationHandler&lt;/code&gt; and configuring a deprecation date, a sunset date, and a migration link per version. Spring then attaches three response headers to every matching request: &lt;code&gt;Deprecation&lt;/code&gt; from RFC 9745, &lt;code&gt;Sunset&lt;/code&gt; from RFC 8594, and a &lt;code&gt;Link&lt;/code&gt; pointing at your migration docs. Clients that watch for these headers learn a version is going away without reading your changelog.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Configuration&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ApiVersioningConfig&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;WebMvcConfigurer&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@Override&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;configureApiVersioning&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ApiVersionConfigurer&lt;/span&gt; &lt;span class="n"&gt;configurer&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;StandardApiVersionDeprecationHandler&lt;/span&gt; &lt;span class="n"&gt;handler&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
                &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;StandardApiVersionDeprecationHandler&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

        &lt;span class="n"&gt;handler&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;configureVersion&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"1.0"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
               &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setDeprecationDate&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ZonedDateTime&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;parse&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"2026-06-01T00:00:00Z"&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
               &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setSunsetDate&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ZonedDateTime&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;parse&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"2026-12-01T00:00:00Z"&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
               &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setSunsetLink&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;URI&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;create&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"https://api.example.com/docs/migrate-to-2.0"&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;

        &lt;span class="n"&gt;configurer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;useRequestHeader&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"X-API-Version"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                  &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;addSupportedVersions&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"1.0"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"2.0"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                  &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setDeprecationHandler&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;handler&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now a call to the deprecated version carries the warning in its response:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;curl -i -H "X-API-Version: 1.0" http://localhost:8080/accounts/42

&lt;/span&gt;&lt;span class="k"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;1.1&lt;/span&gt; &lt;span class="m"&gt;200&lt;/span&gt; &lt;span class="ne"&gt;OK&lt;/span&gt;
&lt;span class="na"&gt;Deprecation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Mon, 01 Jun 2026 00:00:00 GMT&lt;/span&gt;
&lt;span class="na"&gt;Sunset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Tue, 01 Dec 2026 00:00:00 GMT&lt;/span&gt;
&lt;span class="na"&gt;Link&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;https://api.example.com/docs/migrate-to-2.0&amp;gt;; rel="sunset"&lt;/span&gt;
&lt;span class="na"&gt;Content-Type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;application/json&lt;/span&gt;
&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.amazonaws.com%2Fuploads%2Farticles%2Feep3k4s4fjmps55cfj5e.webp" 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.amazonaws.com%2Fuploads%2Farticles%2Feep3k4s4fjmps55cfj5e.webp" alt="HTTP response trace showing Deprecation, Sunset, and Link headers emitted by Spring Framework 7 for a deprecated API version" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The version still works. Deprecation is a signal, not a shutdown. When the sunset date passes and you're confident traffic has moved, you drop the &lt;code&gt;1.0&lt;/code&gt; handler and remove &lt;code&gt;"1.0"&lt;/code&gt; from the supported versions. Clients that ignored the headers get a clean 400, and you have the access logs to prove you warned them.&lt;/p&gt;

&lt;h2&gt;
  
  
  How was this done before Spring 7?
&lt;/h2&gt;

&lt;p&gt;Before Spring 7 you versioned by hand, and every approach had a sharp edge. The most common pattern was duplicate controllers, one class per version, wired to different base paths:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@RestController&lt;/span&gt;
&lt;span class="nd"&gt;@RequestMapping&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/v1/accounts"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AccountControllerV1&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;@RestController&lt;/span&gt;
&lt;span class="nd"&gt;@RequestMapping&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/v2/accounts"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AccountControllerV2&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That works until you have twenty endpoints across four versions and a bug fix has to land in three of them. The other common hack abused the &lt;code&gt;headers&lt;/code&gt; condition on a mapping:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@GetMapping&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"/accounts/{id}"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"X-API-Version=1"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;AccountV1&lt;/span&gt; &lt;span class="nf"&gt;getV1&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;@PathVariable&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;String equality only. No &lt;code&gt;1.0&lt;/code&gt; versus &lt;code&gt;1&lt;/code&gt; normalization, no baseline ranges, no &lt;code&gt;1.5&lt;/code&gt; falling back to &lt;code&gt;1.0&lt;/code&gt;. You hand-rolled every comparison. Teams that wanted real semantics wrote a custom &lt;code&gt;HandlerInterceptor&lt;/code&gt; to parse and validate versions, then threaded the result through &lt;code&gt;ThreadLocal&lt;/code&gt; or request attributes. It was a lot of code to maintain, and it lived nowhere near the mappings it controlled.&lt;/p&gt;

&lt;p&gt;The built-in approach wins on every axis I care about. Versioning lives on the mapping where you can see it, comparison is semantic, baseline matching cuts handler count, and deprecation headers come free. The 300 lines I deleted were exactly this kind of glue.&lt;/p&gt;

&lt;h2&gt;
  
  
  What pitfalls should you watch for?
&lt;/h2&gt;

&lt;p&gt;The first pitfall is the required-version default, which surprises everyone. A request with no version returns a 400, not your newest handler. If you want missing versions to fall back instead of failing, set a default and mark versions optional:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="n"&gt;configurer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;useRequestHeader&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"X-API-Version"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
          &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;addSupportedVersions&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"1.0"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"2.0"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
          &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setVersionRequired&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
          &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setDefaultVersion&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"2.0"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;setVersionRequired(false)&lt;/code&gt; and no default, Spring falls back to the most recent supported version, which may not be what you want during a migration. Always pair optional versions with an explicit &lt;code&gt;setDefaultVersion&lt;/code&gt; so the fallback is a decision, not an accident.&lt;/p&gt;

&lt;p&gt;The second pitfall is supported-version detection. By default Spring initializes the supported set from the versions it finds on your controller mappings, so a typo like &lt;code&gt;version = "20"&lt;/code&gt; silently becomes a supported version. If you want a locked allowlist, call &lt;code&gt;detectSupportedVersions(false)&lt;/code&gt; and declare every version with &lt;code&gt;addSupportedVersions&lt;/code&gt; yourself. I do this on public APIs so nothing ships a version by accident.&lt;/p&gt;

&lt;p&gt;The third pitfall is OpenAPI tooling. As of mid-2026, springdoc support for the new &lt;code&gt;version&lt;/code&gt; attribute is still catching up, so two handlers on the same path can confuse schema generation. Until your springdoc version understands the version dimension, group your docs per version or document the version header manually in your OpenAPI config. Test your generated spec before you assume it rendered both versions.&lt;/p&gt;

&lt;p&gt;The last one is strategy drift. Once you pick a resolver, every endpoint must use it. A header-versioned API with one path-versioned endpoint will route inconsistently and break client SDKs that assume one scheme. Decide the strategy at the start of the project and enforce it in review.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is built-in versioning worth migrating to?
&lt;/h2&gt;

&lt;p&gt;Yes, and it changes how you think about API evolution, not just how you wire it. When adding a version is one attribute and one method, you stop dreading breaking changes and start shipping them cleanly, because the cost of carrying an old contract next to a new one dropped to almost nothing. That's the real shift in Spring Framework 7. The mechanics are simple. The freedom they buy is the point.&lt;/p&gt;

&lt;p&gt;Start with header versioning and a required version on a single endpoint. Add a &lt;code&gt;2.0&lt;/code&gt; handler with a baseline range. Wire a deprecation handler with a sunset date six months out. Once that loop feels natural, you'll version everything this way and wonder how you tolerated the interceptor era.&lt;/p&gt;

&lt;p&gt;For the authoritative details, read the official &lt;a href="https://spring.io/blog/2025/09/16/api-versioning-in-spring" rel="noopener noreferrer"&gt;Spring blog announcement on API versioning&lt;/a&gt; and the &lt;a href="https://docs.spring.io/spring-framework/reference/web/webmvc-versioning.html" rel="noopener noreferrer"&gt;Spring Framework reference on MVC API versioning&lt;/a&gt;. For the header semantics, see &lt;a href="https://www.rfc-editor.org/rfc/rfc9745.html" rel="noopener noreferrer"&gt;RFC 9745 (Deprecation)&lt;/a&gt; and &lt;a href="https://www.rfc-editor.org/rfc/rfc8594.html" rel="noopener noreferrer"&gt;RFC 8594 (Sunset)&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Keep Reading
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://www.rabinarayanpatra.com/blogs/spring-boot-cors-guide" rel="noopener noreferrer"&gt;How to Configure CORS in Spring Boot&lt;/a&gt;. Get cross-origin headers right before you expose a versioned API.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.rabinarayanpatra.com/blogs/spring-boot-testcontainers-guide" rel="noopener noreferrer"&gt;How to Test Spring Boot with Testcontainers&lt;/a&gt;. Write integration tests that exercise each API version against a real database.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.rabinarayanpatra.com/blogs/spring-security-component-revolution" rel="noopener noreferrer"&gt;Spring Security's Component Revolution&lt;/a&gt;. Secure the versioned endpoints you just built.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.rabinarayanpatra.com/blogs/modern-java-spring-boot" rel="noopener noreferrer"&gt;Modern Java with Spring Boot&lt;/a&gt;. The language features that make Spring Boot 4 controllers cleaner.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>api</category>
      <category>java</category>
      <category>springboot</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>How to Set Up an SSH Tunnel for Local Database Access</title>
      <dc:creator>Rabinarayan Patra</dc:creator>
      <pubDate>Thu, 28 May 2026 18:30:00 +0000</pubDate>
      <link>https://dev.to/rabinarayanpatra/how-to-set-up-an-ssh-tunnel-for-local-database-access-1nkk</link>
      <guid>https://dev.to/rabinarayanpatra/how-to-set-up-an-ssh-tunnel-for-local-database-access-1nkk</guid>
      <description>&lt;p&gt;The single fastest way to wake up to a ransom note is to expose your Postgres port directly to the public internet. The second fastest is to think you can secure it with a strong password and call it a day.&lt;/p&gt;

&lt;p&gt;I have run point on database access for production systems at three different companies. Every single one of them had at least one engineer at some point ask if we could "just open 5432 to my IP" so they could pull a quick report from DBeaver. The answer is always no. The right answer is an SSH tunnel, and once you have done it twice, it takes about 15 seconds to set up.&lt;/p&gt;

&lt;p&gt;This post is the practical guide I wish I had given that engineer the first time. We will cover the command, the GUI client setup for the four databases I touch most often, the autossh recipe that keeps the tunnel alive through laptop sleeps and network changes, and the small set of mistakes that bite people in production. If you want the deeper context on why exposing database ports is bad even with strong auth, I covered that in my &lt;a href="https://www.rabinarayanpatra.com/blogs/zero-trust-microservices-spring-security" rel="noopener noreferrer"&gt;zero-trust microservices post&lt;/a&gt;, and the connection pooling tradeoffs once you are inside the tunnel show up in &lt;a href="https://www.rabinarayanpatra.com/blogs/postgres-connection-pool-pgbouncer-survival-guide" rel="noopener noreferrer"&gt;my pgbouncer survival guide&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is an SSH tunnel and why use it for database access?
&lt;/h2&gt;

&lt;p&gt;An SSH tunnel for database access forwards a local TCP port on your laptop through an authenticated SSH session to a remote host, which then opens a connection to the actual database. Your database client connects to &lt;code&gt;localhost&lt;/code&gt; and never knows anything else exists. All traffic rides inside the encrypted SSH channel.&lt;/p&gt;

&lt;p&gt;The shape of it looks like this.&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.amazonaws.com%2Fuploads%2Farticles%2Fn5nlcb4ymgavml4djgvm.webp" 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.amazonaws.com%2Fuploads%2Farticles%2Fn5nlcb4ymgavml4djgvm.webp" alt="SSH tunnel flow from laptop to bastion to database" width="799" height="452"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This pattern wins on five things at once.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No exposed database port.&lt;/strong&gt; The database listens only on its private network. The bastion is the only thing on the public internet, and it only speaks SSH.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No firewall holes per developer.&lt;/strong&gt; Every engineer goes through the same bastion. You add or remove access by adding or removing SSH keys, not by editing security group rules.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Audit trail.&lt;/strong&gt; Every connection logs through SSH and through the bastion's auth.log. You know who connected, from where, when.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Works with every client.&lt;/strong&gt; psql, DBeaver, TablePlus, DataGrip, mysql, redis-cli, mongosh. They all just see &lt;code&gt;localhost&lt;/code&gt;. No driver-level config needed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Encrypted in transit by default.&lt;/strong&gt; Even if your database speaks plaintext on the wire (looking at you, Redis), the tunnel carries it under SSH.&lt;/p&gt;

&lt;p&gt;The one thing it does not give you is high availability. The tunnel is a long-lived TCP connection between exactly two hosts. If your bastion goes down, your tunnel goes with it. That is fine for ad-hoc developer access. It is not fine for application traffic. Application traffic belongs on a private network or a managed bastion service like AWS Session Manager.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you set up a local port forward to a remote Postgres?
&lt;/h2&gt;

&lt;p&gt;The full command is one line.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh &lt;span class="nt"&gt;-L&lt;/span&gt; 5433:dbhost.internal:5432 ec2-user@bastion.example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read it left to right.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;-L 5433:dbhost.internal:5432&lt;/code&gt; says forward local port &lt;code&gt;5433&lt;/code&gt; on this laptop, through the SSH connection, to &lt;code&gt;dbhost.internal:5432&lt;/code&gt; resolved from the bastion's perspective.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ec2-user@bastion.example.com&lt;/code&gt; is the SSH connection itself.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;While that command is running, anything on your laptop that connects to &lt;code&gt;localhost:5433&lt;/code&gt; ends up talking to the Postgres on &lt;code&gt;dbhost.internal:5432&lt;/code&gt;. Close the terminal or hit Ctrl+C and the tunnel dies.&lt;/p&gt;

&lt;p&gt;I deliberately use &lt;code&gt;5433&lt;/code&gt; on the local side instead of &lt;code&gt;5432&lt;/code&gt;. If you happen to have Postgres running locally for development, you do not want to clobber it. Pick a high port that does not collide.&lt;/p&gt;

&lt;p&gt;To test the tunnel works without firing up a GUI, use psql from another terminal.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;psql &lt;span class="nt"&gt;-h&lt;/span&gt; localhost &lt;span class="nt"&gt;-p&lt;/span&gt; 5433 &lt;span class="nt"&gt;-U&lt;/span&gt; app_user &lt;span class="nt"&gt;-d&lt;/span&gt; production
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;-h localhost&lt;/code&gt; is the key. Without it, psql tries to connect via Unix socket and skips the tunnel entirely. I have lost 20 minutes to this twice.&lt;/p&gt;

&lt;h3&gt;
  
  
  Running the tunnel in the background
&lt;/h3&gt;

&lt;p&gt;If you do not want a terminal sitting open, add &lt;code&gt;-f -N&lt;/code&gt; and the command returns immediately while the tunnel keeps running.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="nt"&gt;-N&lt;/span&gt; &lt;span class="nt"&gt;-L&lt;/span&gt; 5433:dbhost.internal:5432 ec2-user@bastion.example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;-N&lt;/code&gt; means do not execute a remote command (we only want the forward).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-f&lt;/code&gt; means fork into the background after authentication.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To kill it later, find and stop the process.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pgrep &lt;span class="nt"&gt;-af&lt;/span&gt; &lt;span class="s2"&gt;"ssh.*5433:dbhost.internal"&lt;/span&gt;
&lt;span class="nb"&gt;kill&lt;/span&gt; &amp;lt;pid&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  A tidier setup with &lt;code&gt;~/.ssh/config&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Typing that command every time gets old. Stash the whole thing in your SSH config.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ssh"&gt;&lt;code&gt;&lt;span class="c1"&gt;# ~/.ssh/config&lt;/span&gt;
&lt;span class="k"&gt;Host&lt;/span&gt; pg-prod
    &lt;span class="k"&gt;HostName&lt;/span&gt; bastion.example.com
    &lt;span class="k"&gt;User&lt;/span&gt; ec2-user
    &lt;span class="k"&gt;IdentityFile&lt;/span&gt; ~/.ssh/keys/prod-bastion.pem
    &lt;span class="k"&gt;LocalForward&lt;/span&gt; &lt;span class="m"&gt;5433&lt;/span&gt; dbhost.internal:5432
    &lt;span class="k"&gt;ServerAliveInterval&lt;/span&gt; &lt;span class="m"&gt;30&lt;/span&gt;
    &lt;span class="k"&gt;ServerAliveCountMax&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;
    &lt;span class="k"&gt;ExitOnForwardFailure&lt;/span&gt; &lt;span class="no"&gt;yes&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the command is just &lt;code&gt;ssh pg-prod&lt;/code&gt;, and you get a few useful behaviors for free.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;ServerAliveInterval 30&lt;/code&gt; sends a keepalive every 30 seconds so the tunnel does not die when your home router decides to drop idle connections.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ExitOnForwardFailure yes&lt;/code&gt; makes ssh fail fast if the forward cannot bind, instead of leaving you with a dead tunnel and no error.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For the background form, &lt;code&gt;ssh -f -N pg-prod&lt;/code&gt; still works. The config entry is purely additive.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you connect from your GUI client through the tunnel?
&lt;/h2&gt;

&lt;p&gt;With the tunnel running, every GUI client connects to &lt;code&gt;localhost&lt;/code&gt; on the forwarded port. The only field that matters is the host. Everything else (username, password, database name) is the same as you would use against the real database.&lt;/p&gt;

&lt;p&gt;In DBeaver, create a new Postgres connection and fill in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;Host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;localhost&lt;/span&gt;
&lt;span class="na"&gt;Port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5433&lt;/span&gt;
&lt;span class="na"&gt;Database&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;production&lt;/span&gt;
&lt;span class="na"&gt;Username&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app_user&lt;/span&gt;
&lt;span class="na"&gt;Password&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;your db password&amp;gt;&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Hit Test Connection. If it works, you are good.&lt;/p&gt;

&lt;p&gt;In TablePlus, same idea. Host is &lt;code&gt;localhost&lt;/code&gt;, port is &lt;code&gt;5433&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;In DataGrip, same. The driver does not know the tunnel exists, which is exactly the point.&lt;/p&gt;

&lt;p&gt;DBeaver and DataGrip both have a built-in SSH tunnel option in their connection dialog. That works too. The advantage of using ssh on the command line instead is that you can share one tunnel across psql, DBeaver, a script, and a notebook at the same time. The advantage of the GUI option is that it dies cleanly when you close the client.&lt;/p&gt;

&lt;p&gt;I default to the command line approach because I almost always have multiple things hitting the same database. Pick what fits your workflow.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you do the same for MySQL, Redis, and MongoDB?
&lt;/h2&gt;

&lt;p&gt;The flag is identical. Only the port changes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# MySQL&lt;/span&gt;
ssh &lt;span class="nt"&gt;-L&lt;/span&gt; 3307:dbhost.internal:3306 user@bastion

&lt;span class="c"&gt;# Redis&lt;/span&gt;
ssh &lt;span class="nt"&gt;-L&lt;/span&gt; 6380:cache.internal:6379 user@bastion

&lt;span class="c"&gt;# MongoDB&lt;/span&gt;
ssh &lt;span class="nt"&gt;-L&lt;/span&gt; 27018:mongohost.internal:27017 user@bastion
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then connect each client to &lt;code&gt;localhost&lt;/code&gt; on the forwarded port.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;mysql &lt;span class="nt"&gt;-h&lt;/span&gt; 127.0.0.1 &lt;span class="nt"&gt;-P&lt;/span&gt; 3307 &lt;span class="nt"&gt;-u&lt;/span&gt; app_user &lt;span class="nt"&gt;-p&lt;/span&gt;
redis-cli &lt;span class="nt"&gt;-h&lt;/span&gt; 127.0.0.1 &lt;span class="nt"&gt;-p&lt;/span&gt; 6380
mongosh &lt;span class="s2"&gt;"mongodb://app_user:secret@127.0.0.1:27018/production"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few client-specific notes that catch people.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;MySQL needs &lt;code&gt;127.0.0.1&lt;/code&gt;, not &lt;code&gt;localhost&lt;/code&gt;.&lt;/strong&gt; The mysql client tries to connect via Unix socket when you say &lt;code&gt;localhost&lt;/code&gt;, just like psql does. Use the IP literal and you skip that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Redis tunnels work great for debugging, badly for sustained throughput.&lt;/strong&gt; Every command round-trips through the SSH session. Latency goes from 0.2 ms to 8-30 ms depending on your link. Fine for &lt;code&gt;redis-cli MONITOR&lt;/code&gt; or one-off lookups. Wrong tool for ETL.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mongo replica sets need extra care.&lt;/strong&gt; A standalone mongod tunnels fine. A replica set will hand you back the internal hostnames of the other replicas during connection negotiation, and your client will then try to connect to those names directly. Either tunnel each replica on its own port and add them to your connection string, or set &lt;code&gt;directConnection=true&lt;/code&gt; in the URI to disable replica discovery.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you keep the tunnel alive with autossh and systemd?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ssh -f -N&lt;/code&gt; works until your laptop goes to sleep, your wifi switches, or the bastion restarts. Then the tunnel dies silently and your next connection just hangs. The fix is autossh, which is a tiny wrapper that monitors the SSH process and restarts it when it drops.&lt;/p&gt;

&lt;p&gt;Install it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# macOS&lt;/span&gt;
brew &lt;span class="nb"&gt;install &lt;/span&gt;autossh

&lt;span class="c"&gt;# Ubuntu / Debian&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;autossh

&lt;span class="c"&gt;# Fedora / Rocky&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;dnf &lt;span class="nb"&gt;install &lt;/span&gt;autossh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it the same way you ran ssh, with one extra port for autossh's own health check.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;AUTOSSH_GATETIME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0 &lt;span class="se"&gt;\&lt;/span&gt;
autossh &lt;span class="nt"&gt;-M&lt;/span&gt; 0 &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="nt"&gt;-N&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s2"&gt;"ServerAliveInterval 30"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s2"&gt;"ServerAliveCountMax 3"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s2"&gt;"ExitOnForwardFailure yes"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-L&lt;/span&gt; 5433:dbhost.internal:5432 &lt;span class="se"&gt;\&lt;/span&gt;
  ec2-user@bastion.example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few details that matter.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;AUTOSSH_GATETIME=0&lt;/code&gt; makes autossh restart immediately even if the first connection failed. Without it, autossh waits 30 seconds before retrying, which is annoying when you mis-typed the host.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-M 0&lt;/code&gt; disables autossh's old monitoring port mechanism. We use the &lt;code&gt;ServerAlive*&lt;/code&gt; SSH options instead, which work better through NATs and modern firewalls.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That command stays up through sleep, wake, and network changes. But it does not survive a reboot. For that, wrap it in a systemd user service.&lt;/p&gt;

&lt;h3&gt;
  
  
  The systemd user service
&lt;/h3&gt;

&lt;p&gt;Create &lt;code&gt;~/.config/systemd/user/pg-tunnel.service&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight systemd"&gt;&lt;code&gt;&lt;span class="k"&gt;[Unit]&lt;/span&gt;
&lt;span class="nt"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;Autossh tunnel to production Postgres
&lt;span class="nt"&gt;After&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;network-online.target
&lt;span class="nt"&gt;Wants&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;network-online.target

&lt;span class="k"&gt;[Service]&lt;/span&gt;
&lt;span class="nt"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;simple
&lt;span class="nt"&gt;Environment&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;AUTOSSH_GATETIME=0
&lt;span class="nt"&gt;ExecStart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;/usr/bin/autossh -M 0 -N &lt;span class="se"&gt;\
&lt;/span&gt;  -o "ServerAliveInterval 30" &lt;span class="se"&gt;\
&lt;/span&gt;  -o "ServerAliveCountMax 3" &lt;span class="se"&gt;\
&lt;/span&gt;  -o "ExitOnForwardFailure yes" &lt;span class="se"&gt;\
&lt;/span&gt;  -o "ConnectTimeout 10" &lt;span class="se"&gt;\
&lt;/span&gt;  -i /home/rabi/.ssh/keys/prod-bastion.pem &lt;span class="se"&gt;\
&lt;/span&gt;  -L 5433:dbhost.internal:5432 &lt;span class="se"&gt;\
&lt;/span&gt;  ec2-user@bastion.example.com
&lt;span class="nt"&gt;Restart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;always
&lt;span class="nt"&gt;RestartSec&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;10

&lt;span class="k"&gt;[Install]&lt;/span&gt;
&lt;span class="nt"&gt;WantedBy&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;default.target
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice the ExecStart does not use &lt;code&gt;-f&lt;/code&gt; here. Systemd wants the process in the foreground so it can supervise it. The &lt;code&gt;Restart=always&lt;/code&gt; line takes over from the &lt;code&gt;-f&lt;/code&gt; background behavior.&lt;/p&gt;

&lt;p&gt;Enable and start.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemctl &lt;span class="nt"&gt;--user&lt;/span&gt; daemon-reload
systemctl &lt;span class="nt"&gt;--user&lt;/span&gt; &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="nt"&gt;--now&lt;/span&gt; pg-tunnel.service
systemctl &lt;span class="nt"&gt;--user&lt;/span&gt; status pg-tunnel.service
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last command should show &lt;code&gt;active (running)&lt;/code&gt;. If you want the service to start at boot (not just at login), enable user lingering once.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;loginctl enable-linger &lt;span class="nv"&gt;$USER&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Logs are in journalctl.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;journalctl &lt;span class="nt"&gt;--user&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; pg-tunnel.service &lt;span class="nt"&gt;-f&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On macOS, the same job goes into a launchd plist at &lt;code&gt;~/Library/LaunchAgents/com.rabi.pg-tunnel.plist&lt;/code&gt;. The shape is similar enough that I will skip the full XML, but the key entries are &lt;code&gt;KeepAlive&lt;/code&gt; set to true and a &lt;code&gt;ProgramArguments&lt;/code&gt; array that mirrors the autossh command above.&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.amazonaws.com%2Fuploads%2Farticles%2Fwx8n0pzyfv8cmfx0qvk7.webp" 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.amazonaws.com%2Fuploads%2Farticles%2Fwx8n0pzyfv8cmfx0qvk7.webp" alt="Autossh under systemd keeps the tunnel alive across reboots" width="799" height="452"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What are the common SSH tunnel mistakes to avoid?
&lt;/h2&gt;

&lt;p&gt;I have made every one of these. So has everyone I have onboarded.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Binding the forward to 0.0.0.0 instead of 127.0.0.1.&lt;/strong&gt; The default &lt;code&gt;-L 5433:dbhost:5432&lt;/code&gt; binds to localhost only. If you write &lt;code&gt;-L *:5433:dbhost:5432&lt;/code&gt;, you have just opened your laptop's port 5433 to anyone on the same wifi network as you. They can now talk to your production Postgres. Do not do this. The local bind defaults to localhost for a reason.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hardcoding production credentials in &lt;code&gt;~/.ssh/config&lt;/code&gt;.&lt;/strong&gt; SSH config has no concept of secrets management. If you check your config into a dotfiles repo, that key path goes with it. Keep production keys outside the config-tracked area and reference them by absolute path from a directory that is gitignored.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Forgetting that &lt;code&gt;ServerAliveInterval&lt;/code&gt; is client-side only.&lt;/strong&gt; This tells your laptop to send keepalives. The bastion can still close the connection on its own idle timeout. If your bastion is AWS Systems Manager based or sits behind a corporate load balancer with a short idle, you also need to set keepalives at the server level (&lt;code&gt;ClientAliveInterval&lt;/code&gt; in sshd_config). Otherwise your tunnel drops every five minutes and you do not know why.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trusting the tunnel to give you encryption end-to-end.&lt;/strong&gt; SSH encrypts the laptop-to-bastion segment. The bastion-to-database segment is in cleartext on your private network. For most production setups, that is fine because the private network is trusted. For sensitive workloads with stricter compliance requirements (PCI, HIPAA), insist on TLS on the database side too, even inside the VPC.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tunneling through your jump host as root.&lt;/strong&gt; The bastion is a public-facing box. The account that holds your tunnel should not also be the one with sudo rights. Use a dedicated low-privilege user for tunneling. Restrict it to &lt;code&gt;command="false",no-shell&lt;/code&gt; in &lt;code&gt;authorized_keys&lt;/code&gt; if you want to be paranoid, with a &lt;code&gt;PermitOpen&lt;/code&gt; directive that limits which &lt;code&gt;host:port&lt;/code&gt; combos this key can forward to.&lt;/p&gt;

&lt;p&gt;The hardened authorized_keys line 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;&lt;span class="nb"&gt;command&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"echo 'no shell',no-pty,no-agent-forwarding,no-X11-forwarding,permitopen="&lt;/span&gt;dbhost.internal:5432&lt;span class="s2"&gt;" ssh-ed25519 AAAA... rabi@laptop

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That key can open exactly one forward, to exactly one host:port, and cannot execute a shell. If the key leaks, the attacker gets a tunnel to one database, not a shell on your bastion.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Letting the tunnel persist across job changes.&lt;/strong&gt; This one is process, not technical. When an engineer leaves the team, their SSH keys come out of every bastion's &lt;code&gt;authorized_keys&lt;/code&gt;. The keys are in your &lt;a href="https://www.rabinarayanpatra.com/snippets/aws-cli/secrets-manager-get" rel="noopener noreferrer"&gt;secrets manager&lt;/a&gt; and config-managed. Right? Right.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Using the same bastion for staging and production.&lt;/strong&gt; I have seen this break twice. A misconfigured &lt;code&gt;LocalForward&lt;/code&gt; ends up pointing your TablePlus at production while you think you are on staging. Use separate bastions, separate config entries with distinct host names like &lt;code&gt;pg-prod&lt;/code&gt; and &lt;code&gt;pg-stage&lt;/code&gt;, and color-code the connections in your client so you cannot confuse them visually.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does the whole setup look like in 30 seconds?
&lt;/h2&gt;

&lt;p&gt;For the reader who skipped to the end, here is the compressed version.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# One-off&lt;/span&gt;
ssh &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="nt"&gt;-N&lt;/span&gt; &lt;span class="nt"&gt;-L&lt;/span&gt; 5433:dbhost.internal:5432 user@bastion
psql &lt;span class="nt"&gt;-h&lt;/span&gt; localhost &lt;span class="nt"&gt;-p&lt;/span&gt; 5433 &lt;span class="nt"&gt;-U&lt;/span&gt; app_user &lt;span class="nt"&gt;-d&lt;/span&gt; production

&lt;span class="c"&gt;# Persistent, survives sleep and network changes&lt;/span&gt;
brew &lt;span class="nb"&gt;install &lt;/span&gt;autossh
&lt;span class="nv"&gt;AUTOSSH_GATETIME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0 autossh &lt;span class="nt"&gt;-M&lt;/span&gt; 0 &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="nt"&gt;-N&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s2"&gt;"ServerAliveInterval 30"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-L&lt;/span&gt; 5433:dbhost.internal:5432 user@bastion

&lt;span class="c"&gt;# Persistent, survives reboot (Linux)&lt;/span&gt;
&lt;span class="c"&gt;# Drop the autossh command into ~/.config/systemd/user/pg-tunnel.service&lt;/span&gt;
&lt;span class="c"&gt;# and run: systemctl --user enable --now pg-tunnel.service&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is genuinely all you need for 95% of the database access situations a developer hits.&lt;/p&gt;

&lt;p&gt;The whole reason this pattern works is that SSH is older than most of the protocols we layer on top of it, and the port-forwarding feature has been battle-tested for three decades. Use it. Stop opening database ports to the internet. Your future self will thank you the next time a Shodan-driven exploit campaign sweeps the internet looking for misconfigured Postgres instances, which is approximately every Tuesday.&lt;/p&gt;

&lt;p&gt;For more on the protocol, see the &lt;a href="https://man.openbsd.org/ssh.1" rel="noopener noreferrer"&gt;OpenSSH manual page for ssh(1)&lt;/a&gt;, the &lt;a href="https://www.harding.motd.ca/autossh/" rel="noopener noreferrer"&gt;autossh project page&lt;/a&gt;, and &lt;a href="https://datatracker.ietf.org/doc/html/rfc4254" rel="noopener noreferrer"&gt;RFC 4254&lt;/a&gt; for the SSH connection protocol spec that defines port forwarding.&lt;/p&gt;

&lt;h3&gt;
  
  
  Keep Reading
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://www.rabinarayanpatra.com/blogs/postgres-connection-pool-pgbouncer-survival-guide" rel="noopener noreferrer"&gt;Postgres Connection Pool: A pgbouncer Survival Guide&lt;/a&gt;: once you can reach the database, the next question is how many connections you can hold open without melting it.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.rabinarayanpatra.com/blogs/zero-trust-microservices-spring-security" rel="noopener noreferrer"&gt;Zero-Trust Microservices with Spring Security&lt;/a&gt;: the bigger frame for why exposing internal ports is the wrong default, even inside a private network.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>database</category>
      <category>postgres</category>
      <category>security</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Spring AI 2.0 MCP Annotations: From Tool to Production</title>
      <dc:creator>Rabinarayan Patra</dc:creator>
      <pubDate>Tue, 26 May 2026 18:30:00 +0000</pubDate>
      <link>https://dev.to/rabinarayanpatra/spring-ai-20-mcp-annotations-from-tool-to-production-1h5d</link>
      <guid>https://dev.to/rabinarayanpatra/spring-ai-20-mcp-annotations-from-tool-to-production-1h5d</guid>
      <description>&lt;p&gt;Spring AI 2.0.0-M6 dropped on May 8, 2026. Buried in the release notes was the thing I had been waiting for since I first wired an MCP server in Java six months ago: native annotations. &lt;code&gt;@McpTool&lt;/code&gt;, &lt;code&gt;@McpResource&lt;/code&gt;, &lt;code&gt;@McpPrompt&lt;/code&gt;, &lt;code&gt;@McpComplete&lt;/code&gt;. All in core. All auto-registered.&lt;/p&gt;

&lt;p&gt;If you've written an MCP server with the older Spring AI &lt;code&gt;ToolCallback&lt;/code&gt; API, you remember the ritual. Build descriptors, register callbacks, wire up the transport manually, handle JSON schema by hand. The annotation API replaces all of it with a single annotation on a method. Spring AI generates the schema. Auto-configuration handles registration. You write the business logic.&lt;/p&gt;

&lt;p&gt;This tutorial walks the full path: building a production MCP server, exposing tools and resources, handling async work with progress reporting, picking a transport, registering with Claude Code, and the gotchas I've hit that nobody mentions on the docs.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed in Spring AI 2.0 MCP annotations?
&lt;/h2&gt;

&lt;p&gt;Spring AI 2.0 collapses MCP server and client wiring into a small set of annotations that Spring Boot auto-configuration picks up at startup. The annotated beans become the MCP surface for your Spring Boot app.&lt;/p&gt;

&lt;p&gt;The core server annotations are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;@McpTool&lt;/code&gt; marks a method as an MCP tool. Spring AI builds the JSON schema from your method parameters.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;@McpResource&lt;/code&gt; exposes a resource via a URI template.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;@McpPrompt&lt;/code&gt; exposes a prompt template that clients can fetch.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;@McpComplete&lt;/code&gt; provides auto-completion for prompt or resource arguments.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The auto-configuration also injects special context parameters like &lt;code&gt;McpSyncRequestContext&lt;/code&gt; and &lt;code&gt;McpAsyncRequestContext&lt;/code&gt;, which give your method access to logging, progress reporting, sampling, and elicitation without polluting the JSON schema.&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.amazonaws.com%2Fuploads%2Farticles%2Fhxj26k80yzaujozkm97p.webp" 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.amazonaws.com%2Fuploads%2Farticles%2Fhxj26k80yzaujozkm97p.webp" alt="Spring AI 2.0 MCP annotations layered architecture: Java annotations through Spring Boot auto-configuration to MCP transport" width="799" height="452"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For a Spring shop, this is the kind of API change that flips a project's complexity. Before, every team building an MCP server wrote the same plumbing. Now the plumbing is gone.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you build an MCP server with @McpTool?
&lt;/h2&gt;

&lt;p&gt;Start with a Spring Boot 3.5+ project on Java 21 or higher. Add the MCP server starter to your &lt;code&gt;pom.xml&lt;/code&gt;. There are three flavors: stdio/SSE default, WebMVC, and WebFlux. For production HTTP transport, pick WebMVC or WebFlux based on whether you want blocking or reactive code.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;dependency&amp;gt;
    &amp;lt;groupId&amp;gt;org.springframework.ai&amp;lt;/groupId&amp;gt;
    &amp;lt;artifactId&amp;gt;spring-ai-starter-mcp-server-webmvc&amp;lt;/artifactId&amp;gt;
    &amp;lt;version&amp;gt;2.0.0-M6&amp;lt;/version&amp;gt;
&amp;lt;/dependency&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Spring AI 2.0 milestones live in the Spring milestone repository, so add it if you haven't:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;repositories&amp;gt;
    &amp;lt;repository&amp;gt;
        &amp;lt;id&amp;gt;spring-milestones&amp;lt;/id&amp;gt;
        &amp;lt;name&amp;gt;Spring Milestones&amp;lt;/name&amp;gt;
        &amp;lt;url&amp;gt;https://repo.spring.io/milestone&amp;lt;/url&amp;gt;
    &amp;lt;/repository&amp;gt;
&amp;lt;/repositories&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now write a tool. The example everyone reaches for first is a calculator, but let's do something a real MCP client would actually want: fetching a user record from your database.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;import org.springframework.ai.mcp.server.annotation.McpTool;
import org.springframework.ai.mcp.server.annotation.McpToolParam;
import org.springframework.stereotype.Component;

@Component
public class UserTools {

    private final UserRepository users;

    public UserTools(UserRepository users) {
        this.users = users;
    }

    @McpTool(
        name = "get_user",
        description = "Fetch a user record by ID. Returns name, email, and signup date."
    )
    public UserDto getUser(
        @McpToolParam(description = "Numeric user ID", required = true) long userId
    ) {
        return users.findById(userId)
            .map(UserDto::from)
            .orElseThrow(() -&amp;gt; new IllegalArgumentException("User not found: " + userId));
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole tool. Spring AI does a few things automatically when it scans this bean:&lt;/p&gt;

&lt;p&gt;It builds a JSON schema for the &lt;code&gt;userId&lt;/code&gt; parameter using the &lt;code&gt;@McpToolParam&lt;/code&gt; description and the Java type. The MCP client sees the schema and shows it as a typed input.&lt;/p&gt;

&lt;p&gt;It registers the method with the MCP server runtime under the name &lt;code&gt;get_user&lt;/code&gt;. The MCP &lt;code&gt;tools/list&lt;/code&gt; request returns it. The &lt;code&gt;tools/call&lt;/code&gt; request invokes it.&lt;/p&gt;

&lt;p&gt;It serializes the return type using Jackson. &lt;code&gt;UserDto&lt;/code&gt; becomes a JSON object in the tool result.&lt;/p&gt;

&lt;p&gt;You can also use &lt;code&gt;Map&amp;lt;String, Object&amp;gt;&lt;/code&gt; if you want loose typing, or a record class if you want strict typing with deserialization on the client side. Records are my default. The serialization is predictable, the schema is implicit, and the code is less than a Lombok annotation pile would be.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you handle progress and async work?
&lt;/h2&gt;

&lt;p&gt;The most common reason a tool feels broken in production is that it does real work without telling the client. The client sits there. The user sits there. Eventually something times out. Spring AI 2.0 fixes this with a request context that exposes a progress channel.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;import org.springframework.ai.mcp.server.annotation.McpTool;
import org.springframework.ai.mcp.server.context.McpSyncRequestContext;
import org.springframework.stereotype.Component;

@Component
public class ReportTools {

    private final ReportService reports;

    public ReportTools(ReportService reports) {
        this.reports = reports;
    }

    @McpTool(
        name = "generate_quarterly_report",
        description = "Generate the quarterly revenue report. Takes a few seconds."
    )
    public ReportResult generateReport(
        McpSyncRequestContext ctx,
        @McpToolParam(description = "Quarter, e.g. 2026-Q1", required = true) String quarter
    ) {
        ctx.logging().info("Loading transactions for " + quarter);
        ctx.progress().report(0.1, "Loading transactions");

        var transactions = reports.loadTransactions(quarter);
        ctx.progress().report(0.5, "Aggregating");

        var aggregated = reports.aggregate(transactions);
        ctx.progress().report(0.9, "Rendering");

        return reports.render(aggregated);
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;McpSyncRequestContext&lt;/code&gt; does not appear in the JSON schema. Spring AI knows it's a framework parameter and excludes it. The user-facing parameters stay clean. Inside the method you get &lt;code&gt;logging()&lt;/code&gt;, &lt;code&gt;progress()&lt;/code&gt;, &lt;code&gt;sampling()&lt;/code&gt;, and &lt;code&gt;elicitation()&lt;/code&gt; channels, all wired to the MCP client transport.&lt;/p&gt;

&lt;p&gt;For reactive code, use &lt;code&gt;McpAsyncRequestContext&lt;/code&gt; and return a &lt;code&gt;Mono&lt;/code&gt; or &lt;code&gt;Flux&lt;/code&gt;. The progress channel returns Reactor types so you can compose progress reporting into a reactive chain.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@McpTool(name = "async_report", description = "Generate report asynchronously.")
public Mono&amp;lt;ReportResult&amp;gt; asyncReport(
    McpAsyncRequestContext ctx,
    @McpToolParam(description = "Quarter") String quarter
) {
    return reports.loadAsync(quarter)
        .doOnNext(_ -&amp;gt; ctx.progress().report(0.5, "Aggregated").subscribe())
        .flatMap(reports::renderAsync);
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One thing to flag: the progress channel is not the same thing as streaming results. Progress is metadata. The result is still a single return value. If you want token-by-token streaming, you want sampling, not tools.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you expose prompts and resources?
&lt;/h2&gt;

&lt;p&gt;Tools are the most-used MCP primitive, but prompts and resources are what turn a tool collection into a workspace. Prompts let the client request a prefilled prompt template. Resources let the client browse data your server exposes by URI.&lt;/p&gt;

&lt;p&gt;A prompt 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;@Component
public class IncidentPrompts {

    @McpPrompt(
        name = "incident_summary",
        description = "Generate an incident summary from a Jira ticket ID."
    )
    public String incidentSummary(
        @McpToolParam(description = "Jira ticket ID, e.g. INC-1234") String ticketId
    ) {
        return """
            Summarize the incident in ticket %s for an executive audience.
            Include: timeline, root cause, customer impact, and remediation.
            """.formatted(ticketId);
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the client asks for the &lt;code&gt;incident_summary&lt;/code&gt; prompt with &lt;code&gt;INC-1234&lt;/code&gt;, the server returns the rendered string. The client passes it to its model.&lt;/p&gt;

&lt;p&gt;Resources are different. They expose data the server holds, keyed by URI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@Component
public class CustomerResources {

    private final CustomerRepository customers;

    public CustomerResources(CustomerRepository customers) {
        this.customers = customers;
    }

    @McpResource(
        uri = "customer://{customerId}/profile",
        description = "Customer profile data including billing address and tier."
    )
    public CustomerProfile profile(String customerId) {
        return customers.profileFor(customerId);
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The client can list resources, pick one, and read it. The URI template is matched on the path variable. Spring AI extracts the &lt;code&gt;customerId&lt;/code&gt; and passes it to the method.&lt;/p&gt;

&lt;p&gt;The combination of tools, prompts, and resources is what makes MCP feel like an actual application surface rather than a function bag. Tools do work. Prompts give the client wording. Resources expose state.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you choose between transports (stdio, SSE, Streamable HTTP)?
&lt;/h2&gt;

&lt;p&gt;Spring AI supports four transports, and the choice matters more than the docs make it sound.&lt;/p&gt;

&lt;p&gt;stdio is for local single-process tools. Claude Code spawns your server as a child process and talks to it over stdin and stdout. Fine for personal tooling. Bad for anything multi-user, multi-tenant, or networked.&lt;/p&gt;

&lt;p&gt;SSE was the original HTTP transport for MCP. It's being phased out. Spring AI still ships it for backward compatibility, but new servers should not start there.&lt;/p&gt;

&lt;p&gt;Streamable HTTP is the current MCP HTTP transport. Stateful, supports bidirectional notifications, works behind reverse proxies, fits production environments. Use this for any server that isn't strictly local.&lt;/p&gt;

&lt;p&gt;Stateless Streamable HTTP is the new variant designed for serverless and horizontally scaled deployments. No session affinity required. The tradeoff is that any context that would have lived in the server session has to come back through the request from the client. If you're deploying to Vercel, Cloud Run, or any autoscaled platform, this is your transport.&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.amazonaws.com%2Fuploads%2Farticles%2F0carykyuewjytm0j7h6r.webp" 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.amazonaws.com%2Fuploads%2Farticles%2F0carykyuewjytm0j7h6r.webp" alt="MCP transport comparison: stdio for local, SSE deprecated, Streamable HTTP for production, Stateless Streamable HTTP for serverless" width="799" height="452"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For Streamable HTTP with WebMVC, the auto-config wires it up at &lt;code&gt;/mcp&lt;/code&gt; by default. You can change the path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;spring:
  ai:
    mcp:
      server:
        transport: streamable-http
        path: /api/mcp
        name: my-spring-server
        version: 1.0.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The name and version show up in the MCP &lt;code&gt;initialize&lt;/code&gt; response. Set them to something recognizable. Default Spring AI names look generic in client UIs.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you register and test the server with Claude Code?
&lt;/h2&gt;

&lt;p&gt;Once your Spring Boot app runs and exposes Streamable HTTP at &lt;code&gt;/mcp&lt;/code&gt;, register it with Claude Code. The config lives in &lt;code&gt;~/.config/claude/mcp.json&lt;/code&gt; on macOS and Linux, or via the &lt;code&gt;claude mcp&lt;/code&gt; CLI.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{
  "mcpServers": {
    "my-spring-server": {
      "type": "streamable-http",
      "url": "http://localhost:8080/mcp"
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Restart Claude Code. Run &lt;code&gt;/mcp&lt;/code&gt; inside a session. You should see your tools, prompts, and resources listed. Try calling one. If the call returns and the result shows up in your transcript, the loop is alive.&lt;/p&gt;

&lt;p&gt;For local stdio testing, swap the JSON to type &lt;code&gt;stdio&lt;/code&gt; and point &lt;code&gt;command&lt;/code&gt; at your packaged jar:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{
  "mcpServers": {
    "my-spring-server": {
      "type": "stdio",
      "command": "java",
      "args": ["-jar", "/path/to/server.jar"]
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Claude Code will spawn the JVM, talk over stdin and stdout, and shut it down when the session ends. The startup cost is real, somewhere between 2 and 5 seconds depending on your dependencies. For HTTP transport, Spring Boot stays running and connections are cheap.&lt;/p&gt;

&lt;h2&gt;
  
  
  What are the production-readiness gotchas?
&lt;/h2&gt;

&lt;p&gt;The annotation API hides the complexity. That's a feature for most cases, but it also means a few production concerns aren't obvious until they bite.&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.amazonaws.com%2Fuploads%2Farticles%2Fuh04teksda0qkyeuhs6a.webp" 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.amazonaws.com%2Fuploads%2Farticles%2Fuh04teksda0qkyeuhs6a.webp" alt="Six production gotchas for Spring AI MCP servers: error handling, schema generation, auth, long-running tools, scaling, tool naming" width="799" height="452"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Error handling.&lt;/strong&gt; If your &lt;code&gt;@McpTool&lt;/code&gt; method throws, Spring AI wraps the exception and returns an MCP error to the client. Good. But the default error mapping returns the exception message verbatim, which can leak internal details. Customize the error handling with a &lt;code&gt;McpExceptionHandler&lt;/code&gt; bean if you're exposing the server to untrusted clients.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Schema generation.&lt;/strong&gt; Records and POJOs work. Generic collections work. Nested polymorphic types are where the generator gets cranky. If your tool returns a &lt;code&gt;List&amp;lt;Animal&amp;gt;&lt;/code&gt; where &lt;code&gt;Animal&lt;/code&gt; is an interface, expect the schema to be vague. Prefer concrete types for tool return values. Save the polymorphism for internal layers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Auth.&lt;/strong&gt; Spring AI does not ship MCP-specific auth. You wire it through normal Spring Security. For Streamable HTTP, add a security filter chain matching &lt;code&gt;/mcp&lt;/code&gt; and validate bearer tokens or API keys there. The Stateless Streamable HTTP transport makes this easier because there's no session to protect, only requests.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Long-running tools.&lt;/strong&gt; MCP clients have timeouts. Claude Code's default is generous but not infinite. If your tool runs longer than 30 seconds, use the progress channel aggressively, and consider returning a job handle and exposing a second tool to poll status. Trying to hold open a 5-minute synchronous tool call will end in tears.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scaling.&lt;/strong&gt; Streamable HTTP is stateful per session. Sticky sessions or a shared session store are needed if you scale horizontally. If you want stateless scaling, use the Stateless Streamable HTTP transport and design your tools to be self-contained on each request.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tool naming.&lt;/strong&gt; MCP tool names show up in the client UI. Pick verbs. &lt;code&gt;get_user&lt;/code&gt; reads better than &lt;code&gt;userQuery&lt;/code&gt;. Consistency matters more than cleverness.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The Spring AI 2.0 MCP annotation API is the kind of upgrade you only notice fully when you look back at the old way. The boilerplate is gone. The schema generation is automatic. The transports are real. The progress and async story is sane.&lt;/p&gt;

&lt;p&gt;The part I want more Spring teams to internalize: MCP is no longer an experiment. Every major coding agent speaks it. Every Spring service in your fleet is a potential MCP surface. The cost of exposing your internal APIs to an agent has dropped to almost nothing. Whether that's a good idea is a different conversation, but the technical barrier is now low enough that you should decide on purpose, not by default.&lt;/p&gt;

&lt;p&gt;For more on Spring AI MCP, see the &lt;a href="https://docs.spring.io/spring-ai/reference/api/mcp/mcp-annotations-overview.html" rel="noopener noreferrer"&gt;Spring AI MCP annotations docs&lt;/a&gt;, the &lt;a href="https://spring.io/blog/2026/05/08/spring-ai-1-0-7-1-1-6-2-0-0-M6-available-now/" rel="noopener noreferrer"&gt;Spring AI 2.0.0-M6 release post&lt;/a&gt;, and the &lt;a href="https://modelcontextprotocol.io/" rel="noopener noreferrer"&gt;official MCP spec&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Keep Reading
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://www.rabinarayanpatra.com/blogs/claude-skills-vs-mcp-vs-projects" rel="noopener noreferrer"&gt;Claude Skills vs MCP vs Projects&lt;/a&gt;. When to reach for an MCP server vs a skill or a project.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.rabinarayanpatra.com/blogs/claude-code-routines-ci-automation" rel="noopener noreferrer"&gt;Claude Code Routines for CI Automation&lt;/a&gt;. Wiring agents into CI once your MCP server is live.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.rabinarayanpatra.com/blogs/modern-java-spring-boot" rel="noopener noreferrer"&gt;Modern Java + Spring Boot&lt;/a&gt;. The Spring Boot 3.x baseline you need before Spring AI 2.0.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>java</category>
      <category>mcp</category>
      <category>springboot</category>
    </item>
    <item>
      <title>Why MCP 2026-07-28 Spec Drops Sessions and Goes Stateless</title>
      <dc:creator>Rabinarayan Patra</dc:creator>
      <pubDate>Sun, 24 May 2026 18:30:00 +0000</pubDate>
      <link>https://dev.to/rabinarayanpatra/why-mcp-2026-07-28-spec-drops-sessions-and-goes-stateless-1gd</link>
      <guid>https://dev.to/rabinarayanpatra/why-mcp-2026-07-28-spec-drops-sessions-and-goes-stateless-1gd</guid>
      <description>&lt;p&gt;The MCP team locked the 2026-07-28 spec release candidate on May 21. It is the largest revision since launch and yes, it breaks things. If you are running an MCP server today, you need to understand what changes before July 28 finalizes the spec.&lt;/p&gt;

&lt;p&gt;I keep seeing people on X call this "MCP 2.0". It is not. The spec uses date versions, not semver. The official name is the 2026-07-28 specification, and it sits as a release candidate until the final freeze on July 28 2026. The ten-week window between now and then is for SDK maintainers and server authors to validate against real workloads.&lt;/p&gt;

&lt;p&gt;The big shift is that MCP is going stateless. The current 2025-11-25 spec treats every client-server connection as a session with handshake, identifier, and lifecycle. The new spec rips most of that out at the protocol layer. Below I walk through what changed, what broke, and whether you should rush your migration or wait.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is the MCP 2026-07-28 spec release candidate?
&lt;/h2&gt;

&lt;p&gt;The 2026-07-28 release candidate is the next major version of the Model Context Protocol, locked on May 21 2026 and finalizing on July 28 2026. It introduces a stateless protocol core, a formal Extensions framework, the Tasks extension for long-running work, MCP Apps for server-rendered UIs, and OAuth-aligned authorization.&lt;/p&gt;

&lt;p&gt;The official MCP roadmap calls this revision "the largest revision of the protocol since launch". Tier 1 SDK maintainers (the official Anthropic-maintained Python and TypeScript SDKs) are expected to ship support within the ten-week validation window. If you maintain a server or build agent infrastructure on top of MCP, this is the version you target next.&lt;/p&gt;

&lt;p&gt;A subtle but useful detail: the MCP team shifted its 2026 roadmap from release-milestone organization to priority-area focus. Four priority areas drive the spec now: Transport Evolution and Scalability, Agent Communication, Governance Maturation, and Enterprise Readiness. Almost every concrete change in the 2026-07-28 spec maps directly to one of those four. That alignment matters because it tells you what to expect in the next spec drop too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why was the 2025-11-25 stateful protocol hard to scale?
&lt;/h2&gt;

&lt;p&gt;The 2025-11-25 spec required every connection to start with an initialize/initialized handshake. The server returned a session identifier in an Mcp-Session-Id response header, and every subsequent request from that client had to carry the same header so the server could route it back to its session state.&lt;/p&gt;

&lt;p&gt;That design is fine for a single-process MCP server running on a developer laptop. It is painful in production. The MCP roadmap calls the problem out directly: "Stateful sessions fight with load balancers, horizontal scaling requires workarounds." When you put a load balancer in front of stateful MCP servers you have three bad options.&lt;/p&gt;

&lt;p&gt;You can run sticky sessions, which fail open whenever an instance restarts or scales down. You can share session state in Redis or a similar store, which adds a network hop and a single point of failure to every tool call. Or you can do deep packet inspection at the gateway, reading Mcp-Session-Id out of the header to manually route traffic, which forces every MCP client to know your private cluster topology.&lt;/p&gt;

&lt;p&gt;In my own work running an MCP server behind a Vercel Function I hit this within a week. Functions are stateless by design. The first attempt to add MCP routing died on cold starts because the next invocation had no idea what session-id the client was carrying. Working around it meant pushing every session into Upstash Redis and eating the round trip on every tools/list call. Not fun.&lt;/p&gt;

&lt;p&gt;The pattern is the same for any horizontally scaled deployment. Cloud Run, AWS Lambda, Kubernetes with HPA, even traditional VMs behind an L7 load balancer all run into the same wall. The old spec quietly assumed long-lived processes with shared memory. The new spec assumes nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  How does the stateless protocol core actually work?
&lt;/h2&gt;

&lt;p&gt;The new spec removes the initialize/initialized handshake and the Mcp-Session-Id header from the base protocol. Each MCP request now carries everything the server needs to route it independently, and clients explicitly thread any state across calls themselves.&lt;/p&gt;

&lt;p&gt;The headline change for ops folks: a stateless MCP server can sit behind a plain round-robin load balancer with no sticky sessions and no shared session store. According to the release notes, gateways can route traffic on the new Mcp-Method header instead of inspecting payloads, and clients can cache tools/list responses for as long as the server's ttlMs field permits. That last part matters because in production a tools/list call on a busy server can dominate the latency budget.&lt;/p&gt;

&lt;p&gt;Here is the shape of the change in terms of request flow.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Before (2025-11-25):
client -&amp;gt; initialize -&amp;gt; server -&amp;gt; 200 + Mcp-Session-Id: abc
client -&amp;gt; tools/list (Mcp-Session-Id: abc) -&amp;gt; server (must own session abc)
client -&amp;gt; tools/call (Mcp-Session-Id: abc) -&amp;gt; same server instance

After (2026-07-28):
client -&amp;gt; tools/list -&amp;gt; load balancer -&amp;gt; server A (returns list + ttlMs)
client -&amp;gt; tools/call -&amp;gt; load balancer -&amp;gt; server B (different instance, same result)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You no longer need the same physical instance to handle both calls. That is the entire ballgame. In practice it means three things for your infrastructure: you can drop sticky session config from your load balancer, you can remove any shared Redis-based session store, and you can stop sending Mcp-Session-Id from your gateway routing rules.&lt;/p&gt;

&lt;p&gt;The new Mcp-Method header is the small detail that pays off for gateway authors. Instead of parsing the JSON-RPC body to know whether a request is tools/list or tools/call, the gateway can read a single header and route by method. That lets you split tools/list (cacheable, idempotent) onto edge nodes and tools/call (mutating, sometimes expensive) onto warm origin nodes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What do Extensions, Tasks, and MCP Apps add?
&lt;/h2&gt;

&lt;p&gt;The 2026-07-28 spec formalizes Extensions as the way new capabilities ship outside the core protocol. The MCP team calls out Extensions as a deliberate move to keep the core small while still letting the ecosystem experiment. New capabilities propose themselves as Extensions first, prove production value, and only graduate into the base spec on the next date-stamped revision.&lt;/p&gt;

&lt;p&gt;Two extensions matter most for new servers right now. The Tasks extension is built for long-running operations that need retries, expiry, and lifecycle tracking. If you have ever tried to wrap a 3-minute search index build inside a single MCP tool call and watched the client time out, Tasks fix that. The roadmap explicitly mentions "Tasks lifecycle refinement (retries, expiry policies)" as a priority for 2026.&lt;/p&gt;

&lt;p&gt;The Tasks model looks roughly like this. Your tool call returns a task handle instead of a result. The client polls or subscribes to the task by id. The server keeps the task's state in whatever store you like (Redis, Postgres, S3), and the client decides how long it is willing to wait. The client and server never need to be in the same process for this to work. A different server instance can resume a task from its persistent state because the task id is the carrier, not a session.&lt;/p&gt;

&lt;p&gt;MCP Apps are the second new extension. They let a server return server-rendered UI components instead of plain text or JSON payloads. The idea is that an MCP server for, say, a payments API can return a real card UI that the host renders inline. This is the closest MCP has come to giving servers presentation control, and it pulls the protocol closer to where Anthropic's Claude Skills and Claude Apps already live.&lt;/p&gt;

&lt;p&gt;Both Extensions and Tasks shipped first as experimental features so the team could collect real-world feedback before locking them into a final spec. Treat them as production-ready in the RC window, but expect minor field renames before July 28.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changes in authorization and error handling?
&lt;/h2&gt;

&lt;p&gt;The new spec aligns authorization more closely with OAuth and OpenID Connect. The earlier spec left auth largely as an implementation detail, which meant every enterprise MCP deployment built its own bearer-token flow. The 2026-07-28 spec defines how OAuth flows interact with MCP transport so a single enterprise SSO setup covers all your MCP servers.&lt;/p&gt;

&lt;p&gt;The roadmap lists "Governance Maturation" and "Enterprise Readiness" as priority areas, with audit trails, SSO auth, and gateway behavior as concrete deliverables. If you have been blocked on MCP rollout because security teams could not approve a custom auth flow, this is the change that unblocks you.&lt;/p&gt;

&lt;p&gt;Error handling also tightens up. The error code for missing resources shifts from the proprietary -32002 to the JSON-RPC standard -32602. Looks like a small change. In practice it means any client that hard-coded -32002 in retry logic now silently swallows the error and retries forever on legitimate not-found responses. I have already seen this fail in a private SDK that hardcoded the old code. Catch both for the next year of mixed deployments.&lt;/p&gt;

&lt;p&gt;A nice secondary effect is that monitoring tools that already understand JSON-RPC standard codes (and there are a lot of them) suddenly understand MCP traffic for free. You lose a small amount of MCP-specific signal in exchange for free integration with every JSON-RPC tracing tool out there. That is a good trade.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which existing code breaks when you upgrade?
&lt;/h2&gt;

&lt;p&gt;The breaking surface is small in lines of code but wide in impact. Here is what I am tracking across my own servers.&lt;/p&gt;

&lt;p&gt;Anywhere your code asserts on Mcp-Session-Id will break. The header is no longer guaranteed and any session-based routing logic needs to come out of your gateway and your client. If you are running NGINX or an Envoy sidecar with rules on Mcp-Session-Id, those rules now do nothing. Worse, they may silently route ALL traffic to one origin because the default fallthrough rule kicks in.&lt;/p&gt;

&lt;p&gt;Anywhere your client uses session state implicitly via the handshake will break. The new spec asks you to thread identifiers explicitly between tool calls. If you call tools/list and store the result keyed by session, you need to switch to keying by server URL plus ttlMs.&lt;/p&gt;

&lt;p&gt;Anywhere your retry logic catches -32002 will silently misbehave. Change it to -32602 or, better, catch both for the next year of mixed deployments while clients catch up.&lt;/p&gt;

&lt;p&gt;Anywhere your gateway does deep packet inspection on the body to route requests will break in a more positive direction. You can rip that logic out and rely on the new Mcp-Method header for routing decisions. Code you delete is code that cannot break in production.&lt;/p&gt;

&lt;p&gt;If you are using a Tier 1 SDK (the Anthropic-maintained Python and TypeScript SDKs), the SDK will hide most of these changes from you within the ten-week validation window. If you are on a community SDK, check whether your maintainer is in the Tier 1 list before you plan a migration date. The SDK tier system is itself new to MCP and tells you which SDKs the spec maintainers actively coordinate with on breaking changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should you migrate before July 28 or after?
&lt;/h2&gt;

&lt;p&gt;It depends on whether you control both the client and the server. If you do, migrate as soon as the SDK you depend on ships RC support, which for Tier 1 should land within four to six weeks. The new protocol is friendlier to the production patterns you already want anyway.&lt;/p&gt;

&lt;p&gt;If you ship a public MCP server consumed by clients you do not control, hold until July 28 and ship support for both spec versions on the same endpoint for at least a quarter. The spec's deprecation policy and the SDK tier system make dual support cheaper than it sounds, since both protocols can share the same handler code with a thin compatibility layer that reads the request and returns either a session-id (for old clients) or no session (for new ones).&lt;/p&gt;

&lt;p&gt;The one case where you should rush is if you are running MCP in a Kubernetes deployment behind an L7 load balancer. The current setup probably has at least one band-aid (sticky sessions, Redis session store, deep packet inspection) that exists only because of the stateful protocol. Migrating lets you delete that code, and code you delete is code that cannot break in production.&lt;/p&gt;

&lt;p&gt;There is also a clear case where you should wait. If your MCP server depends on a community SDK that is not in the Tier 1 list, you do not have a guarantee that the SDK will ship 2026-07-28 support before July 28 itself. Plan a quarter of slack. Do not promise a migration date until the SDK author publishes their own.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does this mean for your MCP servers?
&lt;/h2&gt;

&lt;p&gt;The 2026-07-28 spec is a clean win for anyone running MCP in production. The stateless core removes the awkward fit between MCP and modern serverless or horizontally scaled deployments, and the Tasks and MCP Apps extensions plug gaps that every real deployment has been working around with custom code.&lt;/p&gt;

&lt;p&gt;The bigger lesson, though, is that MCP is starting to act like an open protocol with real production users, not a research experiment from Anthropic. Date-stamped versioned specs, an SEP process, formal SDK tiers, and a published roadmap with priority areas all push it in the direction of HTTP or LSP, not a vendor SDK. That is a healthy sign even if the immediate migration is annoying.&lt;/p&gt;

&lt;p&gt;If you are starting a new MCP server this week, target the 2026-07-28 RC directly. If you have an existing one, start with deleting your Mcp-Session-Id routing rules and your shared session store. Most teams will find that the migration shrinks their MCP stack rather than grows it, which is the right direction for a protocol that is supposed to be the lowest-friction way to give an LLM access to tools.&lt;/p&gt;

&lt;p&gt;For more on the new spec, see the official &lt;a href="https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/" rel="noopener noreferrer"&gt;2026-07-28 Release Candidate announcement&lt;/a&gt;, the &lt;a href="https://blog.modelcontextprotocol.io/posts/2026-mcp-roadmap/" rel="noopener noreferrer"&gt;2026 MCP Roadmap&lt;/a&gt;, and the current &lt;a href="https://modelcontextprotocol.io/specification/2025-11-25" rel="noopener noreferrer"&gt;2025-11-25 specification&lt;/a&gt; for the baseline you are migrating from.&lt;/p&gt;

&lt;h3&gt;
  
  
  Keep Reading
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://www.rabinarayanpatra.com/blogs/build-stateless-mcp-server-2026-07-28-spec" rel="noopener noreferrer"&gt;How to Build a Stateless MCP Server for the 2026-07-28 Spec&lt;/a&gt;. The applied tutorial that ships with code once you understand the protocol shift this post covers.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.rabinarayanpatra.com/blogs/spring-ai-2-mcp-annotations-tutorial" rel="noopener noreferrer"&gt;Spring AI 2.0 MCP Annotations: From Tool to Production&lt;/a&gt;. How the Spring AI 2.0 abstractions sit on top of MCP and what they hide from you.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.rabinarayanpatra.com/blogs/claude-skills-vs-mcp-vs-projects" rel="noopener noreferrer"&gt;Claude Skills vs MCP vs Projects: Which One Should You Use?&lt;/a&gt;. When to pick MCP at all, given the other ways Claude exposes capabilities to agents.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>architecture</category>
      <category>llm</category>
      <category>mcp</category>
      <category>news</category>
    </item>
    <item>
      <title>PostgreSQL 18 Temporal Foreign Keys with Spring Boot JPA</title>
      <dc:creator>Rabinarayan Patra</dc:creator>
      <pubDate>Thu, 21 May 2026 18:30:00 +0000</pubDate>
      <link>https://dev.to/rabinarayanpatra/postgresql-18-temporal-foreign-keys-with-spring-boot-jpa-lcc</link>
      <guid>https://dev.to/rabinarayanpatra/postgresql-18-temporal-foreign-keys-with-spring-boot-jpa-lcc</guid>
      <description>&lt;p&gt;PostgreSQL 18 shipped temporal foreign keys. The kind of feature SQL standards committees promised back in SQL:2011 and most database vendors quietly ignored. Now it's in mainline PG and the Java ecosystem has almost no tutorials on how to use it from Spring Boot.&lt;/p&gt;

&lt;p&gt;I went looking for "Spring Boot temporal foreign key" guides last week. The top results were 2018 Baeldung posts on hand-rolled &lt;code&gt;validFrom/validTo&lt;/code&gt; columns with zero database-level constraints. Plenty of &lt;code&gt;BETWEEN&lt;/code&gt; queries. Plenty of "remember to add an index". No one talks about PG18 yet. So I built a working example, hit every gotcha, and wrote it up.&lt;/p&gt;

&lt;p&gt;This post walks the full path: what temporal FKs actually solve, the PostgreSQL 18 syntax, how to map range columns in Hibernate, the Spring Data repository patterns that work, and the ON DELETE behavior that will absolutely surprise you if you skip the docs.&lt;/p&gt;

&lt;h2&gt;
  
  
  What problem do temporal foreign keys solve?
&lt;/h2&gt;

&lt;p&gt;A temporal foreign key enforces that a child row's time range fits entirely inside its parent row's time range, at the database level, on every insert and update. That's the part regular foreign keys can't do.&lt;/p&gt;

&lt;p&gt;Take a classic example. An employee changes departments three times in five years. A project assignment references the employee. The business rule is: a project assignment can only exist during a period when the employee was actually employed.&lt;/p&gt;

&lt;p&gt;The hand-rolled approach looks like this:&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;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;employees&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;emp_id&lt;/span&gt; &lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;valid_from&lt;/span&gt; &lt;span class="nb"&gt;DATE&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;valid_to&lt;/span&gt; &lt;span class="nb"&gt;DATE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;emp_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;valid_from&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;project_assignments&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;assignment_id&lt;/span&gt; &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;emp_id&lt;/span&gt; &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;assignment_start&lt;/span&gt; &lt;span class="nb"&gt;DATE&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;assignment_end&lt;/span&gt; &lt;span class="nb"&gt;DATE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;FOREIGN&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;emp_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;employees&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;emp_id&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;Every Spring shop I've worked at had a variation of this. And every one of them had bugs. Project assignments referencing employee IDs whose valid period ended six months ago. Manual &lt;code&gt;BETWEEN&lt;/code&gt; checks in service layers that someone forgot to update. Audit failures during quarterly reviews. The data model lied about what it was.&lt;/p&gt;

&lt;p&gt;PG18 fixes this at the constraint level. The constraint is the truth, not a service-layer hope.&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.amazonaws.com%2Fuploads%2Farticles%2Fn75q7atw3lhwbfzhq0ma.webp" 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.amazonaws.com%2Fuploads%2Farticles%2Fn75q7atw3lhwbfzhq0ma.webp" alt="PostgreSQL 18 temporal foreign keys connecting employees with overlapping validity periods to project assignments" width="799" height="452"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How does PostgreSQL 18 implement WITHOUT OVERLAPS and PERIOD?
&lt;/h2&gt;

&lt;p&gt;PG18 introduces two new clauses: &lt;code&gt;WITHOUT OVERLAPS&lt;/code&gt; for primary and unique keys, and &lt;code&gt;PERIOD&lt;/code&gt; for foreign keys. Both rely on range types and GiST indexes under the hood.&lt;/p&gt;

&lt;p&gt;Here's the employees table redone the right way:&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;CREATE&lt;/span&gt; &lt;span class="n"&gt;EXTENSION&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;btree_gist&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;employees&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;emp_id&lt;/span&gt; &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;department&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;salary&lt;/span&gt; &lt;span class="nb"&gt;NUMERIC&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="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;valid_period&lt;/span&gt; &lt;span class="n"&gt;daterange&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;emp_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;valid_period&lt;/span&gt; &lt;span class="k"&gt;WITHOUT&lt;/span&gt; &lt;span class="k"&gt;OVERLAPS&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 things worth pointing out.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;btree_gist&lt;/code&gt; extension is required because the primary key mixes a regular &lt;code&gt;BIGINT&lt;/code&gt; column with a range column. PG needs a GiST index that can handle both, and &lt;code&gt;btree_gist&lt;/code&gt; provides the btree operator support inside GiST. If you forget it, the &lt;code&gt;CREATE TABLE&lt;/code&gt; will fail with a confusing operator error.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;valid_period&lt;/code&gt; column uses &lt;code&gt;daterange&lt;/code&gt;, one of PostgreSQL's built-in range types. You can also use &lt;code&gt;tstzrange&lt;/code&gt; for timestamp ranges or &lt;code&gt;int4range&lt;/code&gt; for numeric ranges. The constraint creates a GiST index automatically. Try to insert two rows for the same &lt;code&gt;emp_id&lt;/code&gt; with overlapping &lt;code&gt;valid_period&lt;/code&gt; values and PG rejects it.&lt;/p&gt;

&lt;p&gt;Now the project assignments table with a real temporal FK:&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;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;project_assignments&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;assignment_id&lt;/span&gt; &lt;span class="n"&gt;BIGSERIAL&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;emp_id&lt;/span&gt; &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;project_name&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;assignment_period&lt;/span&gt; &lt;span class="n"&gt;daterange&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

    &lt;span class="k"&gt;FOREIGN&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;emp_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;PERIOD&lt;/span&gt; &lt;span class="n"&gt;assignment_period&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;employees&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;emp_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;PERIOD&lt;/span&gt; &lt;span class="n"&gt;valid_period&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;The &lt;code&gt;PERIOD&lt;/code&gt; keyword tells PG that the last column is a range. The check is not equality. The check is containment: the parent's matching rows must, in combination, fully cover the child's range. If the employee's &lt;code&gt;valid_period&lt;/code&gt; ends June 1 2024 and the project assignment runs March 1 to August 1 2024, the insert fails because June 1 to August 1 has no parent row covering it.&lt;/p&gt;

&lt;p&gt;You can verify this with a deliberate bad insert:&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;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;employees&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;emp_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;department&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;salary&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;valid_period&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;VALUES&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="s1"&gt;'Alice'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Engineering'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;80000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;daterange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'2024-01-01'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'2024-06-01'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;project_assignments&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;emp_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;project_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;assignment_period&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;VALUES&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="s1"&gt;'Migration'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;daterange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'2024-03-01'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'2024-08-01'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="c1"&gt;-- ERROR: insert or update on table "project_assignments" violates foreign key constraint&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The constraint catches it. No service code needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you map daterange columns in Hibernate?
&lt;/h2&gt;

&lt;p&gt;Hibernate 6 added native support for PostgreSQL range types through &lt;code&gt;PostgreSQLRangeJdbcType&lt;/code&gt;, but the cleanest path for a Spring Boot app is still the &lt;code&gt;hypersistence-utils&lt;/code&gt; library. It's the rebranded version of what most of us used as &lt;code&gt;hibernate-types-52&lt;/code&gt; for years, maintained by Vlad Mihalcea.&lt;/p&gt;

&lt;p&gt;Add the dependency to your &lt;code&gt;pom.xml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;dependency&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;groupId&amp;gt;&lt;/span&gt;io.hypersistence&lt;span class="nt"&gt;&amp;lt;/groupId&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;artifactId&amp;gt;&lt;/span&gt;hypersistence-utils-hibernate-63&lt;span class="nt"&gt;&amp;lt;/artifactId&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;version&amp;gt;&lt;/span&gt;3.10.7&lt;span class="nt"&gt;&amp;lt;/version&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/dependency&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you're on Spring Boot 3.5+, you'll be on Hibernate 6.6, so use the &lt;code&gt;hypersistence-utils-hibernate-63&lt;/code&gt; artifact. Older Spring Boot 3.x versions use &lt;code&gt;-62&lt;/code&gt;. The version numbers track Hibernate ORM, not Spring Boot.&lt;/p&gt;

&lt;p&gt;Now the entity:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;io.hypersistence.utils.hibernate.type.range.Range&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;io.hypersistence.utils.hibernate.type.range.PostgreSQLRangeType&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;jakarta.persistence.*&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.hibernate.annotations.Type&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;java.math.BigDecimal&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;java.time.LocalDate&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="nd"&gt;@Entity&lt;/span&gt;
&lt;span class="nd"&gt;@Table&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"employees"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nd"&gt;@IdClass&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;EmployeeId&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Employee&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@Id&lt;/span&gt;
    &lt;span class="nd"&gt;@Column&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"emp_id"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;empId&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="nd"&gt;@Id&lt;/span&gt;
    &lt;span class="nd"&gt;@Type&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;PostgreSQLRangeType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="nd"&gt;@Column&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"valid_period"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;columnDefinition&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"daterange"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;Range&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;LocalDate&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;validPeriod&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="nd"&gt;@Column&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nullable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="nd"&gt;@Column&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nullable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;department&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="nd"&gt;@Column&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nullable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;BigDecimal&lt;/span&gt; &lt;span class="n"&gt;salary&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// getters, setters, constructors&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few things to flag.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;@IdClass(EmployeeId.class)&lt;/code&gt; is needed because the primary key is composite. The &lt;code&gt;EmployeeId&lt;/code&gt; class is a plain Java record or class with the two ID fields and &lt;code&gt;equals&lt;/code&gt;/&lt;code&gt;hashCode&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt; &lt;span class="nf"&gt;EmployeeId&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;empId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Range&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;LocalDate&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;validPeriod&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;Serializable&lt;/span&gt; &lt;span class="o"&gt;{}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;@Type(PostgreSQLRangeType.class)&lt;/code&gt; annotation is what makes Hibernate emit and read &lt;code&gt;daterange&lt;/code&gt; correctly. The &lt;code&gt;columnDefinition = "daterange"&lt;/code&gt; part is what makes JPA's schema generation produce the right column type if you let JPA create the schema. In production, you should use Flyway or Liquibase, not JPA schema generation, but it's worth being explicit either way.&lt;/p&gt;

&lt;p&gt;You construct ranges like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;Range&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;LocalDate&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;period&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Range&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;closedOpen&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
    &lt;span class="nc"&gt;LocalDate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2024&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
    &lt;span class="nc"&gt;LocalDate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2024&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;closedOpen&lt;/code&gt; matches PostgreSQL's &lt;code&gt;[)&lt;/code&gt; notation, which is the most common range form for temporal data. Half-open intervals make adjacent ranges easy to reason about: &lt;code&gt;[Jan 1, Jun 1)&lt;/code&gt; and &lt;code&gt;[Jun 1, Dec 1)&lt;/code&gt; are adjacent, not overlapping.&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.amazonaws.com%2Fuploads%2Farticles%2F21r8mq7ktydzlnpb6nmo.webp" 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.amazonaws.com%2Fuploads%2Farticles%2F21r8mq7ktydzlnpb6nmo.webp" alt="Hibernate range type mapping: Java Range&lt;LocalDate&gt; through hypersistence-utils to PostgreSQL daterange" width="799" height="452"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you write the entity and repository?
&lt;/h2&gt;

&lt;p&gt;Spring Data JPA does most of the work, but you need a custom query for the overlap check at the application level. Database constraints catch invalid inserts, but the app still needs to ask "who was employed during this period?"&lt;/p&gt;

&lt;p&gt;The repository:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;EmployeeRepository&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;JpaRepository&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Employee&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;EmployeeId&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@Query&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"""
        SELECT *
        FROM employees
        WHERE emp_id = :empId
          AND valid_period &amp;amp;&amp;amp; :period
        """&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;nativeQuery&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Employee&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;findOverlapping&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="nd"&gt;@Param&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"empId"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;empId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="nd"&gt;@Param&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"period"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;period&lt;/span&gt;
    &lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt; operator is PostgreSQL's range-overlap operator. You pass the range as a string in PG range literal form: &lt;code&gt;[2024-01-01,2024-06-01)&lt;/code&gt;. JPA doesn't have a native range-binding mechanism, so the string approach is the pragmatic move. If you want strong typing, Vlad's library offers &lt;code&gt;Range.toString()&lt;/code&gt; which formats correctly.&lt;/p&gt;

&lt;p&gt;Here's a service method that finds the employee record valid for a given date:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Service&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;EmploymentService&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;EmployeeRepository&lt;/span&gt; &lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;EmploymentService&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;EmployeeRepository&lt;/span&gt; &lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;repo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;Optional&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Employee&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;findAt&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;empId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;LocalDate&lt;/span&gt; &lt;span class="n"&gt;at&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;singleDay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;format&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"[%s,%s]"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;at&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;at&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findOverlapping&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;empId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;singleDay&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;stream&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;findFirst&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For the project assignment side, the insert just works. Hibernate sends the daterange, PG checks the temporal FK, and if the assignment period isn't fully covered by some employee period, the transaction rolls back.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Transactional&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;ProjectAssignment&lt;/span&gt; &lt;span class="nf"&gt;assign&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;empId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;projectName&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Range&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;LocalDate&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;period&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;ProjectAssignment&lt;/span&gt; &lt;span class="n"&gt;pa&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;ProjectAssignment&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;pa&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setEmpId&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;empId&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;pa&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setProjectName&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;projectName&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;pa&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setAssignmentPeriod&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;period&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;assignmentRepo&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;save&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pa&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the period extends past the employee's validity, you get a &lt;code&gt;DataIntegrityViolationException&lt;/code&gt; wrapping the PG error. Catch it where your error handling needs it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What are the gotchas with ON DELETE and temporal foreign keys?
&lt;/h2&gt;

&lt;p&gt;This is the one that will burn you if you skip the docs. PostgreSQL 18 does not support &lt;code&gt;CASCADE&lt;/code&gt;, &lt;code&gt;RESTRICT&lt;/code&gt;, &lt;code&gt;SET NULL&lt;/code&gt;, or &lt;code&gt;SET DEFAULT&lt;/code&gt; referential actions on temporal foreign keys. Only &lt;code&gt;NO ACTION&lt;/code&gt; is allowed.&lt;/p&gt;

&lt;p&gt;From the official PG18 CREATE TABLE docs: "In a temporal foreign key, this option is not supported." That sentence appears under every action except &lt;code&gt;NO ACTION&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;What does this mean in practice? If you delete an employee row that has dependent project assignments, PG raises a foreign key violation. You have to delete the assignments first, manually, in application code or in a database trigger you write yourself.&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;-- This will fail at the second statement&lt;/span&gt;
&lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;employees&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;emp_id&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="c1"&gt;-- ERROR: update or delete on table "employees" violates foreign key constraint&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The workaround patterns I've seen:&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.amazonaws.com%2Fuploads%2Farticles%2Flf70o6gjg63n22jsnegr.webp" 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.amazonaws.com%2Fuploads%2Farticles%2Flf70o6gjg63n22jsnegr.webp" alt="Three workaround patterns for ON DELETE on temporal foreign keys: app-level delete, soft delete, period close" width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The first is application-level deletion. Spring service methods that delete child rows before parent rows, wrapped in a &lt;code&gt;@Transactional&lt;/code&gt; boundary. This is fine for small graphs, but it doesn't scale to deep hierarchies.&lt;/p&gt;

&lt;p&gt;The second is soft delete on the parent. Add a &lt;code&gt;deleted_at&lt;/code&gt; column, never actually delete rows, and let the temporal FK stay intact forever. Most enterprise systems do this anyway for audit reasons.&lt;/p&gt;

&lt;p&gt;The third is to flip the model: instead of deleting the parent, you close the parent's period by updating &lt;code&gt;valid_period&lt;/code&gt; to end at today's date. Future child inserts will fail. Existing child rows stay valid because their periods were covered when they were created.&lt;/p&gt;

&lt;p&gt;The third approach is closest to the spirit of temporal modeling. You don't lose history. You just declare "this record stopped being valid at this point" and let the constraints enforce that going forward.&lt;/p&gt;

&lt;p&gt;If you absolutely need cascading deletes, you can write a trigger that does the deletion manually before the parent delete fires. But at that point you're rebuilding what the standard wanted to give you, and the SQL spec authors decided cascade semantics on overlapping periods were too ambiguous to specify. They might be right.&lt;/p&gt;

&lt;h2&gt;
  
  
  When should you NOT use temporal foreign keys?
&lt;/h2&gt;

&lt;p&gt;Temporal foreign keys are powerful, and like most powerful features they're easy to overuse. I've seen teams reach for them in places where regular FKs would be simpler and just as correct.&lt;/p&gt;

&lt;p&gt;Skip temporal FKs when:&lt;/p&gt;

&lt;p&gt;You only need audit history. If the question is "what changed when?" and not "what was the state of the world on date X?", a separate audit log table with regular FKs is simpler. Frameworks like Hibernate Envers handle this well.&lt;/p&gt;

&lt;p&gt;The child rows don't have an independent time dimension. If a project assignment is just "associated with employee 1 forever", you don't need temporal FKs. A regular FK with a &lt;code&gt;created_at&lt;/code&gt; timestamp is fine.&lt;/p&gt;

&lt;p&gt;You're modeling a single current state. CRUD apps where the latest row is the only thing that matters don't need temporal modeling. Add a &lt;code&gt;valid_period&lt;/code&gt; column and you've created complexity you'll have to pay for in every query.&lt;/p&gt;

&lt;p&gt;You can't pay the GiST index cost. GiST indexes are slower for point lookups than btree indexes. For high-throughput single-row reads, you'll feel it. Benchmark first.&lt;/p&gt;

&lt;p&gt;When the model genuinely is temporal, the constraint-enforced version is a huge improvement over the hand-rolled version. The number of bugs you avoid by having PG check every insert is real. The cost of writing the queries against ranges instead of timestamps is real too, but it's a one-time cost. The bugs are forever.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;PostgreSQL 18 temporal foreign keys are one of the most under-discussed major-version features I've seen in a while. The Spring Boot ecosystem has barely caught up. If you're modeling employment, contracts, pricing tiers, policies, or anything else where state has duration, the new &lt;code&gt;WITHOUT OVERLAPS&lt;/code&gt; and &lt;code&gt;PERIOD&lt;/code&gt; clauses are worth learning even if you don't ship them tomorrow.&lt;/p&gt;

&lt;p&gt;The piece I want more people to understand: this changes what your database can be the source of truth for. Before PG18, "this assignment must fall inside an employment period" was a service-layer concern that drifted out of sync. Now it's a constraint. The database tells the truth.&lt;/p&gt;

&lt;p&gt;For more on the PG18 release, see the &lt;a href="https://www.postgresql.org/docs/release/18.0/" rel="noopener noreferrer"&gt;PostgreSQL 18.0 release notes&lt;/a&gt;, the &lt;a href="https://neon.com/postgresql/postgresql-18/temporal-constraints" rel="noopener noreferrer"&gt;Neon writeup on temporal constraints&lt;/a&gt;, and the &lt;a href="https://www.postgresql.org/docs/current/sql-createtable.html" rel="noopener noreferrer"&gt;CREATE TABLE syntax docs&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Keep Reading
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://www.rabinarayanpatra.com/blogs/spring-boot-testcontainers-guide" rel="noopener noreferrer"&gt;Spring Boot Testcontainers Guide&lt;/a&gt;. Real PG18 in tests beats mocked databases for catching constraint bugs early.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.rabinarayanpatra.com/blogs/hibernate-lazy-init-guide" rel="noopener noreferrer"&gt;Hibernate Lazy Init Guide&lt;/a&gt;. The other Hibernate gotcha that bites every Spring team.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.rabinarayanpatra.com/blogs/postgres-connection-pool-pgbouncer-survival-guide" rel="noopener noreferrer"&gt;PgBouncer Survival Guide&lt;/a&gt;. Connection pooling matters more when GiST indexes are doing real work.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>database</category>
      <category>java</category>
      <category>postgres</category>
      <category>springboot</category>
    </item>
    <item>
      <title>Next.js 16.2 AGENTS.md and next-browser: A Hands-On Guide</title>
      <dc:creator>Rabinarayan Patra</dc:creator>
      <pubDate>Tue, 28 Apr 2026 18:30:00 +0000</pubDate>
      <link>https://dev.to/rabinarayanpatra/nextjs-162-agentsmd-and-next-browser-a-hands-on-guide-k65</link>
      <guid>https://dev.to/rabinarayanpatra/nextjs-162-agentsmd-and-next-browser-a-hands-on-guide-k65</guid>
      <description>&lt;p&gt;I run Claude Code on my Next.js portfolio every day. For the last year my routine has been the same: paste the relevant Next.js 16 doc into chat, watch the agent write something that worked in Next.js 13, correct it, paste another doc, repeat. The agent's training data was stale, the context window was precious, and I was the middleman.&lt;/p&gt;

&lt;p&gt;Next.js 16.2 quietly killed that routine. Shipped on March 18, 2026 by Jude Gao, Jimmy Lai, Tim Neutkens and the rest of the Next.js team, the release ships &lt;code&gt;AGENTS.md&lt;/code&gt; by default, bundles the entire Next.js docs as plain Markdown inside &lt;code&gt;node_modules/next/dist/docs/&lt;/code&gt;, and adds an experimental &lt;code&gt;@vercel/next-browser&lt;/code&gt; CLI that lets agents inspect a running app through shell commands. Vercel ran their internal evals and saw 100% pass rate with &lt;code&gt;AGENTS.md&lt;/code&gt; vs 79% with the best skill-based setup. That is a big enough gap to change how I scaffold projects.&lt;/p&gt;

&lt;p&gt;This post walks through what actually shipped, how to add it to an existing project, and what I changed in my own workflow after upgrading &lt;code&gt;rabinarayanpatra.com&lt;/code&gt; to 16.2.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is AGENTS.md in Next.js 16.2?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;AGENTS.md&lt;/code&gt; is a Markdown file at the root of a Next.js project that tells AI coding agents to read the version-matched docs bundled inside &lt;code&gt;node_modules/next/dist/docs/&lt;/code&gt; before writing any code. It is not a skill, it is not a plugin, and it is not loaded on demand. It is always-on context that any agent respecting the &lt;code&gt;AGENTS.md&lt;/code&gt; convention will pick up at the start of every turn.&lt;/p&gt;

&lt;p&gt;The default file that &lt;code&gt;create-next-app&lt;/code&gt; generates is small. Here is the shape of it:&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="c"&gt;&amp;lt;!-- BEGIN:nextjs-agent-rules --&amp;gt;&lt;/span&gt;

&lt;span class="gh"&gt;# Next.js: ALWAYS read docs before coding&lt;/span&gt;

Before any Next.js work, find and read the relevant doc in &lt;span class="sb"&gt;`node_modules/next/dist/docs/`&lt;/span&gt;. Your training data is outdated. The docs are the source of truth.

&lt;span class="c"&gt;&amp;lt;!-- END:nextjs-agent-rules --&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;BEGIN&lt;/code&gt; and &lt;code&gt;END&lt;/code&gt; comment markers delimit the Next.js-managed section. Future codemods will only rewrite what is inside those markers, so anything you add outside stays yours. That is a small design choice with a big payoff, because it means you can keep your own agent rules next to the framework's without fighting merge conflicts.&lt;/p&gt;

&lt;p&gt;The second piece is the bundled docs themselves. The &lt;code&gt;next&lt;/code&gt; npm package now ships the full doc tree as plain Markdown files. Open &lt;code&gt;node_modules/next/dist/docs/&lt;/code&gt; in your editor and you will see the same content as nextjs.org, organized by section. A compressed index file is also shipped at around 8 KB, an 80% reduction from the raw 40 KB of full docs, which is what the agent reads first to find the right file to pull into context.&lt;/p&gt;

&lt;p&gt;Third, on projects already set up for Claude Code, there is a tiny convention link:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@AGENTS.md
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;@&lt;/code&gt; directive tells Claude Code to include the contents of &lt;code&gt;AGENTS.md&lt;/code&gt; whenever it loads &lt;code&gt;CLAUDE.md&lt;/code&gt;. So the bootstrap chain becomes &lt;code&gt;CLAUDE.md&lt;/code&gt; pulls in &lt;code&gt;AGENTS.md&lt;/code&gt;, &lt;code&gt;AGENTS.md&lt;/code&gt; tells the agent to read &lt;code&gt;node_modules/next/dist/docs/&lt;/code&gt; whenever Next.js work is happening, and the agent writes code against the real 16.2 API rather than a memory of Next.js 13.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you add AGENTS.md to an existing Next.js project?
&lt;/h2&gt;

&lt;p&gt;On Next.js 16.2 or later, you already have the docs on disk. You just need to create the two files yourself or let the codemod do it. The codemod is the faster path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx @next/codemod@latest agents-md
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That scaffolds &lt;code&gt;AGENTS.md&lt;/code&gt; with the managed directive block and creates or updates &lt;code&gt;CLAUDE.md&lt;/code&gt; with the &lt;code&gt;@AGENTS.md&lt;/code&gt; include. I ran it on the portfolio and it was a one-second change plus two new files in the diff.&lt;/p&gt;

&lt;p&gt;If you are on an older version of Next.js, upgrade first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx @next/codemod@canary upgrade latest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then re-run the &lt;code&gt;agents-md&lt;/code&gt; codemod. The upgrade is where the doc bundle actually lands on disk, so you need 16.2 for the whole flow to work.&lt;/p&gt;

&lt;p&gt;The part I liked most is the escape hatch. If your project already has a sprawling &lt;code&gt;AGENTS.md&lt;/code&gt; with your own conventions, you can drop the managed block into it and keep everything else. The codemod respects existing files and only touches what is inside the markers. That matters for teams who have been writing their own agent rules for months.&lt;/p&gt;

&lt;p&gt;One pitfall I hit on my first attempt: my &lt;code&gt;.gitignore&lt;/code&gt; had &lt;code&gt;node_modules/&lt;/code&gt; ignored (obviously), but Claude Code was still happy to read from it, since it is a local file read, not a git operation. So you do not need to commit the docs, you just need the package installed. CI builds, however, need to run &lt;code&gt;npm install&lt;/code&gt; before an agent can use the docs, which is the usual order anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does the @vercel/next-browser CLI actually do?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;@vercel/next-browser&lt;/code&gt; is an experimental CLI that wraps a persistent Chromium instance with React DevTools pre-loaded and exposes browser data through shell commands. Instead of an agent trying to understand a DevTools panel it cannot see, the agent runs a shell command, gets back structured text, and reasons about the output.&lt;/p&gt;

&lt;p&gt;You install it as a skill:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx skills add vercel-labs/next-browser
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then trigger it inside an agent that supports skills by typing &lt;code&gt;/next-browser&lt;/code&gt;. Claude Code and Cursor both work. The first run boots a Chromium instance you do not see, loads React DevTools into it, and holds the session open across commands.&lt;/p&gt;

&lt;p&gt;At release the feature set covers five buckets. Component trees with props, hooks, state, and source-mapped file locations. PPR shell analysis, which identifies what is static and what is blocking. Errors and logs from the dev server. Network activity since the last navigation, including server actions. And visual capture with screenshots or loading filmstrips.&lt;/p&gt;

&lt;p&gt;The concrete example in the release post is my favorite, because it shows the workflow end-to-end. You have a blog post page with a visitor counter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&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;generateStaticParams&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;getAllPosts&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;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;slug&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="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;BlogPost&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="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;params&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;views&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;getVisitorCount&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;slug&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// per-request&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="nt"&gt;h1&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;h1&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;span&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;views&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; views&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;span&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="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;content&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;article&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;Every slug is enumerated ahead of time, so the post content should prerender at build. But &lt;code&gt;getVisitorCount&lt;/code&gt; runs on every request and sits at the top level, which drags the entire page out of the static shell. The user sees a loading skeleton instead of the post content streaming in.&lt;/p&gt;

&lt;p&gt;An agent can diagnose this by locking PPR mode and inspecting the shell:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;next-browser ppr lock
next-browser goto /blog/hello
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With PPR locked, only the static shell renders. In this case the shell is the loading skeleton, because nothing from the page made it in. Running &lt;code&gt;ppr unlock&lt;/code&gt; gives the agent a structured report:&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;# PPR Shell Analysis&lt;/span&gt;
&lt;span class="gh"&gt;# 1 dynamic hole, 1 static&lt;/span&gt;

blocked by:
&lt;span class="p"&gt;  -&lt;/span&gt; getVisitorCount (server-fetch)
    owner: BlogPost at app/blog/[slug]/page.tsx:5
    next step: Push the fetch into a smaller Suspense leaf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The agent now knows what the blocker is, where it lives, and what to do. It wraps the counter in a Suspense boundary:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&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;BlogPost&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="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;params&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;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="nt"&gt;h1&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;h1&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;Suspense&lt;/span&gt; &lt;span class="na"&gt;fallback&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;lt;&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;... views&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt;&lt;span class="p"&gt;&amp;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="nc"&gt;VisitorCount&lt;/span&gt; &lt;span class="na"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&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;slug&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="nc"&gt;Suspense&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="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;content&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;article&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;Run &lt;code&gt;ppr lock&lt;/code&gt; again and the shell has grown. The post content prerenders instantly, and only the view count falls back to the Suspense placeholder. The agent did that without ever opening a browser window.&lt;/p&gt;

&lt;p&gt;The thing I keep coming back to is that the CLI is designed around one-shot commands against a persistent session. That matches how LLM tool-calling works today. The agent does not manage browser state, the CLI does. The agent just asks questions and parses replies, which is exactly the interaction pattern that works for a model.&lt;/p&gt;

&lt;h2&gt;
  
  
  How does the dev server lock file help AI agents?
&lt;/h2&gt;

&lt;p&gt;Next.js 16.2 writes the running dev server's PID, port, and URL into &lt;code&gt;.next/dev/lock&lt;/code&gt;. When a second &lt;code&gt;next dev&lt;/code&gt; starts in the same directory, Next.js reads the lock file and prints an actionable error instead of a generic port-conflict message:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;Error: Another next dev server is already running.

- Local: http://localhost:3000
- PID: 12345
- Dir: /path/to/project
- Log: .next/dev/logs/next-development.log

Run kill 12345 to stop it.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On paper this is quality-of-life for humans. In practice, this is the single change that saves me the most time with Claude Code.&lt;/p&gt;

&lt;p&gt;Before 16.2, my workflow was: I start the dev server in a pane, Claude Code spawns its own &lt;code&gt;next dev&lt;/code&gt; to check something, the command hangs because port 3000 is taken, Claude Code retries on another port, I get a second server I do not want. Now the agent gets a clean error, a PID to kill, a URL to connect to, and a log path to tail. It makes the right call the first time.&lt;/p&gt;

&lt;p&gt;The lock file also prevents two &lt;code&gt;next build&lt;/code&gt; processes from running at once, which could otherwise corrupt build artifacts. That is a genuine bug I have seen in CI where a re-run was queued before the first finished. Now the second run refuses to start.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you forward browser logs to the terminal?
&lt;/h2&gt;

&lt;p&gt;By default, Next.js 16.2 forwards browser errors to the dev terminal. You do not have to switch to the browser console to see client-side errors, which is another quality-of-life win for agents who cannot open a DevTools panel at all.&lt;/p&gt;

&lt;p&gt;The level is configurable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&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;logging&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;browserToTerminal&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="c1"&gt;// 'error': errors only (default)&lt;/span&gt;
    &lt;span class="c1"&gt;// 'warn': warnings and errors&lt;/span&gt;
    &lt;span class="c1"&gt;// true: all console output&lt;/span&gt;
    &lt;span class="c1"&gt;// false: disabled&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;I ran this at &lt;code&gt;true&lt;/code&gt; for a week and it was too noisy for a typical debugging session. I rolled it back to &lt;code&gt;'warn'&lt;/code&gt; which is the right default for me. Errors alone is usually too narrow, because half the client-side issues I chase start as warnings that the agent would have caught earlier with a wider filter.&lt;/p&gt;

&lt;p&gt;Pairing this with the agent DevTools is the real win. The agent gets errors in the terminal, runs &lt;code&gt;next-browser&lt;/code&gt; for a closer look, fixes the code, and moves on. No browser console, no screenshots to interpret, no back-and-forth with me asking what the page looked like.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does Vercel say AGENTS.md beat skills in their evals?
&lt;/h2&gt;

&lt;p&gt;The numbers from Vercel's internal eval are worth quoting straight. They tested four configurations against a suite of Next.js 16 APIs that did not exist in model training data, like &lt;code&gt;'use cache'&lt;/code&gt;, &lt;code&gt;cacheTag()&lt;/code&gt;, &lt;code&gt;forbidden()&lt;/code&gt;, and &lt;code&gt;proxy.ts&lt;/code&gt;:&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.amazonaws.com%2Fuploads%2Farticles%2Fq80tsbm1khxyfv2h3riq.webp" 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.amazonaws.com%2Fuploads%2Farticles%2Fq80tsbm1khxyfv2h3riq.webp" alt="Pass rate on Next.js 16 evals across four configurations: baseline 53 percent, skill default 53 percent, skill with explicit instructions 79 percent, AGENTS.md 100 percent" width="799" height="422"&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;Configuration&lt;/th&gt;
&lt;th&gt;Pass rate&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Baseline (no docs)&lt;/td&gt;
&lt;td&gt;53%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Skill (default invocation)&lt;/td&gt;
&lt;td&gt;53%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Skill with explicit instructions&lt;/td&gt;
&lt;td&gt;79%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AGENTS.md docs index&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The skill with default invocation matched baseline exactly. Vercel's explanation for that is brutal: "In 56% of eval cases, the skill was never invoked." The agent did not know it needed to look something up, so it did not.&lt;/p&gt;

&lt;p&gt;Explicit instructions pushed skills to 79%, which is a real improvement, but it took careful prompt wording to get there. Phrasing like "You MUST invoke" caused agents to anchor on the docs and miss project context. "Explore project first, then invoke skill" was better. That kind of prompt sensitivity is not something I want to maintain for a team.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;AGENTS.md&lt;/code&gt; hit 100% across build, lint, and test eval categories. The authors called out the mental model: the goal is to shift agents "from pre-training-led reasoning to retrieval-led reasoning." Always-on context wins over on-demand retrieval because there is no decision point the agent can get wrong.&lt;/p&gt;

&lt;p&gt;That matches my experience. The moments I lost the most time with Claude Code were the moments it was confidently wrong, using old APIs it remembered from training. It did not ping the docs because it did not know it needed to. &lt;code&gt;AGENTS.md&lt;/code&gt; removes that failure mode by making the docs non-negotiable.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changes did Next.js 16.2 make to rendering performance?
&lt;/h2&gt;

&lt;p&gt;The agent story is the headline, but 16.2 also ships a big performance jump that is easy to miss. The team landed a change in React (PR #35776) that replaces &lt;code&gt;JSON.parse&lt;/code&gt; with a reviver callback with &lt;code&gt;JSON.parse&lt;/code&gt; followed by a recursive walk in pure JavaScript. The result is up to 350% faster RSC payload deserialization and 25% to 60% faster real-world server rendering.&lt;/p&gt;

&lt;p&gt;The root cause is a V8 quirk. &lt;code&gt;JSON.parse&lt;/code&gt; with a reviver crosses the C++/JavaScript boundary once per key-value pair, and even a no-op reviver makes parsing around 4x slower than without one. Replacing that with a two-step parse-then-walk eliminates the per-key cost.&lt;/p&gt;

&lt;p&gt;The measured numbers are the kind of data I trust because they come from real apps:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Workload&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;th&gt;Speedup&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1000-item Server Component table&lt;/td&gt;
&lt;td&gt;19ms&lt;/td&gt;
&lt;td&gt;15ms&lt;/td&gt;
&lt;td&gt;26%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Server Component with nested Suspense&lt;/td&gt;
&lt;td&gt;80ms&lt;/td&gt;
&lt;td&gt;60ms&lt;/td&gt;
&lt;td&gt;33%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Payload CMS homepage&lt;/td&gt;
&lt;td&gt;43ms&lt;/td&gt;
&lt;td&gt;32ms&lt;/td&gt;
&lt;td&gt;34%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Payload CMS with rich text&lt;/td&gt;
&lt;td&gt;52ms&lt;/td&gt;
&lt;td&gt;33ms&lt;/td&gt;
&lt;td&gt;60%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;You do not need to change any code. Upgrade to 16.2 and &lt;code&gt;react@latest&lt;/code&gt; and the speedup lands. The heavier your RSC payload, the bigger the win, which is why the rich-text case hits 60%.&lt;/p&gt;

&lt;p&gt;Next.js 16.2 also makes &lt;code&gt;ImageResponse&lt;/code&gt; 2x faster for basic images and up to 20x faster for complex ones, with the default font switched from Noto Sans to Geist Sans. Dev startup is around 87% faster than 16.1 on the default application, so the time between &lt;code&gt;next dev&lt;/code&gt; and a ready localhost shrank noticeably on my machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does AGENTS.md replace Claude Code skills entirely?
&lt;/h2&gt;

&lt;p&gt;No, and I do not think it should. &lt;code&gt;AGENTS.md&lt;/code&gt; and skills solve different problems.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;AGENTS.md&lt;/code&gt; is for always-on framework context. Your agent needs to know Next.js 16 every time it writes a Next.js component. That is not an on-demand concern, it is a default concern. Putting it in &lt;code&gt;AGENTS.md&lt;/code&gt; removes the decision point about whether to fetch the docs, which is exactly the decision agents are bad at.&lt;/p&gt;

&lt;p&gt;Skills are for scoped, opt-in capabilities. A skill that runs a specific CLI, calls a private API, or triggers a deploy is something you want the agent to invoke deliberately. The decision point is the feature. You do not want the agent triggering your prod deploy on every turn.&lt;/p&gt;

&lt;p&gt;The Vercel team made the right call putting framework docs into &lt;code&gt;AGENTS.md&lt;/code&gt; and keeping the &lt;code&gt;next-browser&lt;/code&gt; debugger as a skill you trigger with &lt;code&gt;/next-browser&lt;/code&gt;. The debugger is a scoped capability. The framework docs are ambient context. Matching each tool to the right mechanism is the actual lesson here.&lt;/p&gt;

&lt;p&gt;My working rule, after a week of running both on the portfolio: if your agent should consult it every time, put it in &lt;code&gt;AGENTS.md&lt;/code&gt;. If your agent should consult it sometimes, make it a skill.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does this mean for the next year of framework design?
&lt;/h2&gt;

&lt;p&gt;Next.js 16.2 is the first mainstream framework release where the primary design target is an AI coding agent, not a human developer. The default scaffolding exports a file the human may never read. The docs are shipped as Markdown because that is what parses cleanly for a model. The dev server writes a lock file so another process can recover without a human. The experimental DevTools are a shell command interface because that is what LLMs can drive.&lt;/p&gt;

&lt;p&gt;This is not a future-looking take. It is already in &lt;code&gt;create-next-app@latest&lt;/code&gt;. And the concrete UX win for me was small but sharp: I stopped pasting docs into chat. That one behavior change saved me an hour a week. Scaled across a team of developers all running agents all day, the time savings compound fast.&lt;/p&gt;

&lt;p&gt;The open question is how many frameworks follow. Spring, FastAPI, Rails, Django, Laravel, every major framework has the same training-data-staleness problem Next.js just solved. I expect the next wave of releases to ship their own &lt;code&gt;AGENTS.md&lt;/code&gt; conventions, bundled docs, and agent-friendly CLI wrappers. Vercel's eval numbers make the business case too strong to ignore.&lt;/p&gt;

&lt;p&gt;For more on this release, see the &lt;a href="https://nextjs.org/blog/next-16-2" rel="noopener noreferrer"&gt;Next.js 16.2 blog post&lt;/a&gt;, the &lt;a href="https://nextjs.org/blog/next-16-2-ai" rel="noopener noreferrer"&gt;Next.js 16.2 AI Improvements post&lt;/a&gt;, and Vercel's &lt;a href="https://vercel.com/blog/agents-md-outperforms-skills-in-our-agent-evals" rel="noopener noreferrer"&gt;AGENTS.md outperforms skills eval writeup&lt;/a&gt;. The RSC payload perf change is &lt;a href="https://github.com/facebook/react/pull/35776" rel="noopener noreferrer"&gt;React PR #35776&lt;/a&gt; if you want to read the actual diff.&lt;/p&gt;

&lt;h3&gt;
  
  
  Keep Reading
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://rabinarayanpatra.com/blogs/building-modern-docs-generator-nextjs-16" rel="noopener noreferrer"&gt;Building a Modern Docs Generator with Next.js 16&lt;/a&gt;. Where I first went deep on Next.js 16's App Router and how I think about docs infrastructure.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://rabinarayanpatra.com/blogs/hello-proxy-ts-nextjs-16" rel="noopener noreferrer"&gt;Hello Proxy in TypeScript and Next.js 16&lt;/a&gt;. The middleware/proxy rename that landed in 16.0, relevant background for anything using &lt;code&gt;proxy.ts&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://rabinarayanpatra.com/blogs/replacing-useeffect-data-fetching-server-actions" rel="noopener noreferrer"&gt;Replacing useEffect Data Fetching with Server Actions&lt;/a&gt;. Server-side patterns that pair naturally with the 16.2 rendering improvements.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://rabinarayanpatra.com/blogs/vercel-ai-gateway-deep-dive" rel="noopener noreferrer"&gt;Vercel AI Gateway Deep Dive&lt;/a&gt;. The other side of the Vercel AI story, if you want the provider routing and observability angle.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>agents</category>
      <category>ai</category>
      <category>nextjs</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
