<?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.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 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>
    <item>
      <title>Amazon S3 Files: AWS Just Turned Object Storage Into a File System</title>
      <dc:creator>Rabinarayan Patra</dc:creator>
      <pubDate>Thu, 09 Apr 2026 18:30:00 +0000</pubDate>
      <link>https://dev.to/rabinarayanpatra/amazon-s3-files-aws-just-turned-object-storage-into-a-file-system-8md</link>
      <guid>https://dev.to/rabinarayanpatra/amazon-s3-files-aws-just-turned-object-storage-into-a-file-system-8md</guid>
      <description>&lt;p&gt;I've been running EFS-to-S3 sync jobs for two years. Cron schedules, lifecycle policies, rsync scripts that break every time someone changes a directory structure. All because S3 couldn't speak file system.&lt;/p&gt;

&lt;p&gt;That changed this week.&lt;/p&gt;

&lt;p&gt;On April 7, 2026, AWS announced &lt;a href="https://aws.amazon.com/blogs/aws/launching-s3-files-making-s3-buckets-accessible-as-file-systems/" rel="noopener noreferrer"&gt;Amazon S3 Files&lt;/a&gt;, a feature that lets you mount any S3 bucket as a shared NFS file system. No gateway. No third-party tool. No data copies. Your applications read and write files, and S3 stores them. That's it.&lt;/p&gt;

&lt;p&gt;And quietly, on April 6, AWS also started &lt;a href="https://aws.amazon.com/about-aws/whats-new/2026/04/s3-default-bucket-security-setting/" rel="noopener noreferrer"&gt;disabling SSE-C encryption by default&lt;/a&gt; on all new S3 buckets. If you're managing encryption keys manually, that one needs your attention too.&lt;/p&gt;

&lt;p&gt;Let me walk through both changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is Amazon S3 Files and how does it work?
&lt;/h2&gt;

&lt;p&gt;Amazon S3 Files gives S3 buckets a fully-featured file system interface using NFS v4.2. You can mount a bucket on EC2, Lambda, EKS, ECS, Fargate, or AWS Batch and interact with your data using standard file operations: &lt;code&gt;open()&lt;/code&gt;, &lt;code&gt;read()&lt;/code&gt;, &lt;code&gt;write()&lt;/code&gt;, &lt;code&gt;ls&lt;/code&gt;, &lt;code&gt;cp&lt;/code&gt;, &lt;code&gt;mv&lt;/code&gt;. No SDK. No API calls. Just a mount point.&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%2Flch1l9n3z8m9o0ajoxcb.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%2Flch1l9n3z8m9o0ajoxcb.webp" alt="S3 Files Architecture Overview - Source: AWS Blog" width="800" height="466"&gt;&lt;/a&gt;&lt;em&gt;Architecture overview of Amazon S3 Files. Source: &lt;a href="https://aws.amazon.com/blogs/aws/launching-s3-files-making-s3-buckets-accessible-as-file-systems/" rel="noopener noreferrer"&gt;AWS News Blog&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;S3 is now the first and only cloud object store that provides native file system access while keeping all data in object storage. Your objects don't move to a separate file system. They stay in S3 with all the durability, lifecycle policies, and access controls you already have.&lt;/p&gt;

&lt;p&gt;S3 Files is generally available in 34 AWS Regions as of launch day.&lt;/p&gt;

&lt;h3&gt;
  
  
  The stage and commit model
&lt;/h3&gt;

&lt;p&gt;This is the part that surprised me. Instead of translating every file write into an immediate S3 PUT, S3 Files batches changes and commits them to S3 roughly every 60 seconds. AWS borrowed this concept from version control.&lt;/p&gt;

&lt;p&gt;Here's what that means in practice:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;You write a file through the NFS mount&lt;/li&gt;
&lt;li&gt;The write lands in a caching layer (built on EFS infrastructure)&lt;/li&gt;
&lt;li&gt;S3 Files aggregates writes within a 60-second window&lt;/li&gt;
&lt;li&gt;Multiple writes to the same file become a single S3 PUT&lt;/li&gt;
&lt;li&gt;The committed object appears in S3 with full consistency&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This batching has two practical benefits. First, it cuts your S3 request costs because ten rapid writes to the same file become one PUT, not ten. Second, if you're using S3 versioning, you don't end up with ten versions of a file that changed in under a minute.&lt;/p&gt;

&lt;p&gt;But it also means there's a ~60-second lag before file changes are visible on the S3 side. If your workflow needs immediate S3 API visibility of written data, you need to account for that delay.&lt;/p&gt;

&lt;p&gt;When there's a conflict (say, someone writes to a file through NFS while another process updates the same object via the S3 API), S3 remains authoritative. The file-side version gets moved to a &lt;code&gt;lost+found&lt;/code&gt; directory with metrics for visibility. No silent data loss.&lt;/p&gt;

&lt;h3&gt;
  
  
  The caching layer under the hood
&lt;/h3&gt;

&lt;p&gt;When you create an S3 Files file system, AWS provisions a caching layer backed by EFS infrastructure. This cache holds three things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Recently read files&lt;/strong&gt; : Hot reads come from cache with sub-millisecond latency&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Recently written files&lt;/strong&gt; : Staged writes waiting for the next commit cycle&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Metadata&lt;/strong&gt; : Directory listings, file attributes, timestamps&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Small file reads are served entirely from the cache. Large file reads (over 1MB) stream directly from S3 and don't incur S3 Files charges. The aggregate read throughput can reach multiple terabytes per second.&lt;/p&gt;

&lt;p&gt;This design is what makes S3 Files cost-effective. You pay file system rates ($0.30/GB-month) only on the data that's actively cached. A petabyte bucket where only 500GB is actively read? You're paying S3 rates for the petabyte and file system rates for the 500GB.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you set up S3 Files on an EC2 instance?
&lt;/h2&gt;

&lt;p&gt;The setup is straightforward. Here's the full flow:&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;# Step 1: Create an S3 Files file system linked to your bucket&lt;/span&gt;
aws s3api create-file-system &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--bucket&lt;/span&gt; my-data-bucket &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--file-system-name&lt;/span&gt; my-fs

&lt;span class="c"&gt;# Step 2: Get the mount target DNS name&lt;/span&gt;
aws s3api describe-file-system &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--bucket&lt;/span&gt; my-data-bucket &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--file-system-name&lt;/span&gt; my-fs &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'FileSystem.MountTargets[0].DnsName'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--output&lt;/span&gt; text

&lt;span class="c"&gt;# Step 3: Mount on your EC2 instance&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;mount &lt;span class="nt"&gt;-t&lt;/span&gt; nfs4 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;nfsvers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;4.2,rsize&lt;span class="o"&gt;=&lt;/span&gt;1048576,wsize&lt;span class="o"&gt;=&lt;/span&gt;1048576 &lt;span class="se"&gt;\&lt;/span&gt;
  fs-mount-target.efs.us-east-1.amazonaws.com:/ &lt;span class="se"&gt;\&lt;/span&gt;
  /mnt/s3data

&lt;span class="c"&gt;# Step 4: Use it like any filesystem&lt;/span&gt;
&lt;span class="nb"&gt;ls&lt;/span&gt; /mnt/s3data
&lt;span class="nb"&gt;cp &lt;/span&gt;local-file.csv /mnt/s3data/uploads/
&lt;span class="nb"&gt;cat&lt;/span&gt; /mnt/s3data/reports/quarterly.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once mounted, every application on that instance can access S3 data through normal file I/O. Python scripts, Java apps, shell scripts, legacy C++ binaries. Nothing needs to know it's talking to S3.&lt;/p&gt;

&lt;p&gt;For persistent mounts across reboots, add it to &lt;code&gt;/etc/fstab&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/fstab entry for S3 Files
&lt;/span&gt;&lt;span class="n"&gt;fs&lt;/span&gt;-&lt;span class="n"&gt;mount&lt;/span&gt;-&lt;span class="n"&gt;target&lt;/span&gt;.&lt;span class="n"&gt;efs&lt;/span&gt;.&lt;span class="n"&gt;us&lt;/span&gt;-&lt;span class="n"&gt;east&lt;/span&gt;-&lt;span class="m"&gt;1&lt;/span&gt;.&lt;span class="n"&gt;amazonaws&lt;/span&gt;.&lt;span class="n"&gt;com&lt;/span&gt;:/ /&lt;span class="n"&gt;mnt&lt;/span&gt;/&lt;span class="n"&gt;s3data&lt;/span&gt; &lt;span class="n"&gt;nfs4&lt;/span&gt; &lt;span class="n"&gt;nfsvers&lt;/span&gt;=&lt;span class="m"&gt;4&lt;/span&gt;.&lt;span class="m"&gt;2&lt;/span&gt;,&lt;span class="n"&gt;rsize&lt;/span&gt;=&lt;span class="m"&gt;1048576&lt;/span&gt;,&lt;span class="n"&gt;wsize&lt;/span&gt;=&lt;span class="m"&gt;1048576&lt;/span&gt;,&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="n"&gt;netdev&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Where does S3 Files beat EFS (and where does it not)?
&lt;/h2&gt;

&lt;p&gt;This is the question I've been testing all week. S3 Files isn't a drop-in EFS replacement for every workload, but for specific patterns it's clearly better.&lt;/p&gt;

&lt;h3&gt;
  
  
  S3 Files wins
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;Why S3 Files&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Large datasets, small active working set&lt;/td&gt;
&lt;td&gt;Pay S3 rates on cold data, file system rates only on hot data&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Legacy app migration to S3&lt;/td&gt;
&lt;td&gt;Zero code changes needed, just mount and go&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AI/ML training data pipelines&lt;/td&gt;
&lt;td&gt;Read training data as files, store as S3 objects&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Agentic AI workloads&lt;/td&gt;
&lt;td&gt;Shared workspace across multiple compute instances&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multi-service data sharing&lt;/td&gt;
&lt;td&gt;Multiple EKS pods or Lambda functions reading the same dataset&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  EFS still wins
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;Why EFS&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;All data is hot, constant read/write&lt;/td&gt;
&lt;td&gt;EFS avoids the commit delay and S3 request overhead&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sub-second write visibility needed&lt;/td&gt;
&lt;td&gt;S3 Files has a ~60-second commit lag&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Windows workloads (SMB)&lt;/td&gt;
&lt;td&gt;S3 Files only supports NFS, no SMB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hard link requirements&lt;/td&gt;
&lt;td&gt;S3 Files doesn't support hard links&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bucket exceeds 50 million objects&lt;/td&gt;
&lt;td&gt;AWS warns about performance at this scale&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Pricing comparison
&lt;/h3&gt;

&lt;p&gt;The pricing math depends entirely on your access pattern:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;S3 Files cached storage&lt;/strong&gt; : $0.30/GB-month (only for actively cached data)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;S3 Files reads (small files)&lt;/strong&gt;: $0.03/GB from cache&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;S3 Files reads (large files, 1MB+)&lt;/strong&gt;: $0 from S3 Files (standard S3 GET charges apply)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;S3 Files writes&lt;/strong&gt; : $0.06/GB&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Compare that to EFS Performance-optimized at $0.30/GB for standard storage and $0.03/GB for reads. The difference shows up at scale: if you have 10TB in a bucket but only touch 200GB regularly, S3 Files costs a fraction of what an equivalent EFS setup would cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  What are the gotchas you should know before adopting S3 Files?
&lt;/h2&gt;

&lt;p&gt;I ran into a few things during my initial testing that are worth flagging.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The 60-second commit window is real.&lt;/strong&gt; If you write a file via NFS and immediately try to read it through the S3 API (using &lt;code&gt;aws s3 cp&lt;/code&gt; or a direct GET), it won't be there yet. Your application logic needs to handle this. For workflows that do writes via NFS and reads via S3 API, consider adding a short wait or checking for object existence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;NFS file locks don't protect against S3 API access.&lt;/strong&gt; If you lock a file through NFS, that lock only applies to other NFS clients. Someone using the S3 API directly can still modify the object. This isn't a bug. It's how the boundary between file system and object store works. But it can bite you if mixed access isn't on your radar.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The 50-million object warning is something to watch.&lt;/strong&gt; AWS recommends caution when a mounted bucket contains more than 50 million objects. Directory listings and metadata operations can slow down at that scale. If you're dealing with buckets that large, consider using S3 prefixes to scope your mount.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No pNFS, Kerberos, or nconnect support.&lt;/strong&gt; If your NFS setup depends on parallel NFS, Kerberos authentication, NFSv4 data retention, or the &lt;code&gt;nconnect&lt;/code&gt; mount option, those aren't available yet at GA. Standard NFS v4.2 features work fine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SMB is not supported.&lt;/strong&gt; Windows workloads that need file system access to S3 still need FSx or a gateway solution.&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%2Fcvslwq19u9pa48apnm8v.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%2Fcvslwq19u9pa48apnm8v.webp" alt="S3 Files Technical Comparison Architecture" width="800" height="800"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why did AWS disable SSE-C encryption by default?
&lt;/h2&gt;

&lt;p&gt;This change flew under the radar next to S3 Files, but it affects every new bucket created after April 6, 2026.&lt;/p&gt;

&lt;p&gt;SSE-C (Server-Side Encryption with Customer-Provided Keys) lets you bring your own encryption key on every PUT and GET request. S3 encrypts and decrypts using your key but never stores it. The idea is maximum control. The reality is operational risk. Lose the key, lose the data forever. AWS can't recover it for you. There's no "forgot my password" option.&lt;/p&gt;

&lt;p&gt;AWS KMS solved this years ago with customer-managed keys (CMKs) that give you full ownership and control, plus key rotation, auditing through CloudTrail, and recovery options. For most workloads, KMS does everything SSE-C does, minus the footgun.&lt;/p&gt;

&lt;p&gt;So AWS made SSE-C opt-in instead of opt-out. Here's how the rollout works:&lt;/p&gt;

&lt;h3&gt;
  
  
  What changes for new buckets
&lt;/h3&gt;

&lt;p&gt;Every new general-purpose S3 bucket created after April 6, 2026 has SSE-C disabled by default. If you try to upload an object with SSE-C headers, you'll get an access denied error unless you explicitly enable SSE-C first.&lt;/p&gt;

&lt;p&gt;To enable SSE-C on a new bucket:&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;# Explicitly allow SSE-C on a new bucket&lt;/span&gt;
aws s3api put-bucket-encryption &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--bucket&lt;/span&gt; my-new-bucket &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--server-side-encryption-configuration&lt;/span&gt; &lt;span class="s1"&gt;'{
    "Rules": [{
      "ApplyServerSideEncryptionByDefault": {
        "SSEAlgorithm": "AES256"
      },
      "BucketKeyEnabled": false
    }],
    "AllowSSEC": true
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  What changes for existing buckets
&lt;/h3&gt;

&lt;p&gt;This is the part that might catch people off guard. AWS is also disabling SSE-C on existing buckets that have &lt;strong&gt;zero SSE-C encrypted objects&lt;/strong&gt;. If you created a bucket, never used SSE-C, but your automation code includes SSE-C headers "just in case," those writes will start failing.&lt;/p&gt;

&lt;p&gt;Existing buckets that actually contain SSE-C objects? No changes. AWS won't touch those.&lt;/p&gt;

&lt;h3&gt;
  
  
  Who this affects
&lt;/h3&gt;

&lt;p&gt;If you're using AWS KMS (SSE-KMS) or S3-managed keys (SSE-S3) for encryption, this change does nothing to you. Your buckets already don't use SSE-C.&lt;/p&gt;

&lt;p&gt;If you're one of the teams still on SSE-C, you'll want to:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Audit which buckets actually use SSE-C (&lt;code&gt;aws s3api get-bucket-encryption&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Plan a migration to KMS for buckets that don't strictly need SSE-C&lt;/li&gt;
&lt;li&gt;Explicitly re-enable SSE-C on new buckets where it's genuinely required&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The rollout covers 37 AWS Regions, including GovCloud and China regions, and will complete over the next few weeks.&lt;/p&gt;

&lt;h2&gt;
  
  
  What do these changes tell us about where S3 is heading?
&lt;/h2&gt;

&lt;p&gt;If I look at S3 Files and the SSE-C default together, they tell the same story: AWS is reducing the reasons you'd reach for anything other than S3.&lt;/p&gt;

&lt;p&gt;Need file system access? You used to need EFS plus sync scripts. Now you mount S3 directly. Need encryption with your own keys? You used to reach for SSE-C. Now AWS is steering you toward KMS, which handles key management for you.&lt;/p&gt;

&lt;p&gt;S3 now stores over 500 trillion objects and handles roughly 200 million requests per second. It turned 20 years old last month. And instead of letting it coast, AWS gave it the most significant capability upgrade since S3 Intelligent-Tiering launched in 2018.&lt;/p&gt;

&lt;p&gt;For my own projects, I'm already replacing two EFS-backed data pipelines with S3 Files mounts. The sync cron jobs are gone. The drift alerts are gone. One mount point, one storage bill, and a 60-second commit window I can easily live with.&lt;/p&gt;

&lt;p&gt;If you've been running parallel storage systems just to get file access to your S3 data, this week is a good week to rethink that architecture.&lt;/p&gt;

&lt;p&gt;For the full details, see the &lt;a href="https://aws.amazon.com/blogs/aws/launching-s3-files-making-s3-buckets-accessible-as-file-systems/" rel="noopener noreferrer"&gt;official S3 Files announcement&lt;/a&gt;, the &lt;a href="https://aws.amazon.com/s3/features/files/" rel="noopener noreferrer"&gt;S3 Files product page&lt;/a&gt;, and the &lt;a href="https://aws.amazon.com/blogs/storage/advanced-notice-amazon-s3-to-disable-the-use-of-sse-c-encryption-by-default-for-all-new-buckets-and-select-existing-buckets-in-april-2026/" rel="noopener noreferrer"&gt;SSE-C security default announcement&lt;/a&gt;.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://dev.to/blogs/ai-driven-anomaly-detection-security"&gt;AI-Driven Anomaly Detection for Security&lt;/a&gt; - How cloud infrastructure and AI work together for real-time threat detection.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dev.to/blogs/implementing-outbox-pattern-cdc-microservices"&gt;Implementing the Outbox Pattern with CDC in Microservices&lt;/a&gt; - Storage design patterns that affect reliability in distributed systems.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dev.to/blogs/the-day-react-patch-broke-the-internet"&gt;The Day a React Patch Broke the Internet&lt;/a&gt; - Another deep dive into an infrastructure event that caught everyone off guard.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>architecture</category>
      <category>aws</category>
      <category>infrastructure</category>
      <category>news</category>
    </item>
    <item>
      <title>Handling Clock and Timezones Correctly in Java</title>
      <dc:creator>Rabinarayan Patra</dc:creator>
      <pubDate>Wed, 07 Jan 2026 18:30:00 +0000</pubDate>
      <link>https://dev.to/rabinarayanpatra/handling-clock-and-timezones-correctly-in-java-1pdm</link>
      <guid>https://dev.to/rabinarayanpatra/handling-clock-and-timezones-correctly-in-java-1pdm</guid>
      <description>&lt;p&gt;Pop quiz: What is wrong with this code?&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;TokenService&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;boolean&lt;/span&gt; &lt;span class="nf"&gt;isExpired&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Token&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// ❌ The Villain&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getExpiresAt&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;isBefore&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;LocalDateTime&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;now&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;It looks innocent. But &lt;code&gt;LocalDateTime.now()&lt;/code&gt; is a hidden dependency on the &lt;strong&gt;server's system clock&lt;/strong&gt;.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Testing is a nightmare&lt;/strong&gt; : How do you test "token expiration" without &lt;code&gt;Thread.sleep()&lt;/code&gt;? You can't control &lt;code&gt;now()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Timezones are ignored&lt;/strong&gt; : If your server is in UTC but &lt;code&gt;LocalDateTime.now()&lt;/code&gt; picks up the system default (e.g., EST), your logic breaks.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0jo62pe0sgi97zv1ovt9.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%2F0jo62pe0sgi97zv1ovt9.webp" alt="Timezone Chaos" width="800" height="800"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How does java.time.Clock solve the hidden time dependency?
&lt;/h2&gt;

&lt;p&gt;Since Java 8, we have had a built-in abstraction for time: &lt;code&gt;java.time.Clock&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Instead of asking the &lt;em&gt;System&lt;/em&gt; for the time, you ask the &lt;em&gt;Clock&lt;/em&gt;. And because the Clock is an object, you can inject it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Define the Bean
&lt;/h3&gt;

&lt;p&gt;In your Spring Boot configuration, define a global Clock bean. best practice is to always force UTC.&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;TimeConfig&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@Bean&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;Clock&lt;/span&gt; &lt;span class="nf"&gt;clock&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="nc"&gt;Clock&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;systemUTC&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;h3&gt;
  
  
  Step 2: Inject It
&lt;/h3&gt;

&lt;p&gt;Now, refactor your service to depend on the Clock.&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="nd"&gt;@RequiredArgsConstructor&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;TokenService&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="c1"&gt;// ✅ The Hero&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;Clock&lt;/span&gt; &lt;span class="n"&gt;clock&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;boolean&lt;/span&gt; &lt;span class="nf"&gt;isExpired&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Token&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Ask the clock for "now"&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getExpiresAt&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;isBefore&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;LocalDateTime&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;now&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;clock&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;h3&gt;
  
  
  Step 3: Testing "Time Travel"
&lt;/h3&gt;

&lt;p&gt;This is where the magic happens. In your tests, you don't use the system clock. You use a &lt;strong&gt;Fixed Clock&lt;/strong&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;class&lt;/span&gt; &lt;span class="nc"&gt;TokenServiceTest&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@Test&lt;/span&gt;
    &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;shouldFindExpiredToken&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// 1. Freeze time at specific date&lt;/span&gt;
        &lt;span class="nc"&gt;Instant&lt;/span&gt; &lt;span class="n"&gt;fixedInstant&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Instant&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;"2023-10-01T10:00:00Z"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="nc"&gt;Clock&lt;/span&gt; &lt;span class="n"&gt;fixedClock&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Clock&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;fixed&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fixedInstant&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;ZoneId&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="s"&gt;"UTC"&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;

        &lt;span class="nc"&gt;TokenService&lt;/span&gt; &lt;span class="n"&gt;service&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;TokenService&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fixedClock&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

        &lt;span class="c1"&gt;// 2. Create a token that expires 1 second BEFORE the fixed time&lt;/span&gt;
        &lt;span class="nc"&gt;Token&lt;/span&gt; &lt;span class="n"&gt;token&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;Token&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setExpiresAt&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;LocalDateTime&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ofInstant&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fixedInstant&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;minusSeconds&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;ZoneId&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="s"&gt;"UTC"&lt;/span&gt;&lt;span class="o"&gt;)));&lt;/span&gt;

        &lt;span class="c1"&gt;// 3. Assert (Value is deterministic!)&lt;/span&gt;
        &lt;span class="n"&gt;assertThat&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;service&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isExpired&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="o"&gt;)).&lt;/span&gt;&lt;span class="na"&gt;isTrue&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;No &lt;code&gt;Thread.sleep()&lt;/code&gt;. No flaky tests. Just pure deterministic logic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why should your production architecture always use UTC?
&lt;/h2&gt;

&lt;p&gt;Dealing with users in Tokyo, London, and New York? Follow this golden rule:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Servers, APIs, and Databases always speak UTC.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&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%2Faq4cwvc00uh4a98ashix.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%2Faq4cwvc00uh4a98ashix.webp" alt="UTC Architecture Flow" width="800" height="800"&gt;&lt;/a&gt;&lt;/p&gt;

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

&lt;p&gt;Always use &lt;code&gt;TIMESTAMP WITH TIME ZONE&lt;/code&gt; (Postgres) or store as UTC &lt;code&gt;DATETIME&lt;/code&gt;. In Java, map this to &lt;code&gt;Instant&lt;/code&gt; or &lt;code&gt;OffsetDateTime&lt;/code&gt;. &lt;strong&gt;Avoid &lt;code&gt;LocalDateTime&lt;/code&gt;&lt;/strong&gt; for storage as it lacks timezone context.&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;@Entity&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;Order&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// ✅ Good: unambiguous point in timeline&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;Instant&lt;/span&gt; &lt;span class="n"&gt;createdAt&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; 

    &lt;span class="c1"&gt;// ❌ Bad: ambiguous (Is this 10 AM Tokyo or 10 AM NY?)&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;LocalDateTime&lt;/span&gt; &lt;span class="n"&gt;deliveryDue&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;h3&gt;
  
  
  2. API Layer
&lt;/h3&gt;

&lt;p&gt;Spring Boot (via Jackson) behaves differently depending on configuration. Force it to standard ISO-8601 UTC.&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;# application.properties
&lt;/span&gt;&lt;span class="py"&gt;spring.jackson.time-zone&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;UTC&lt;/span&gt;
&lt;span class="py"&gt;spring.jackson.serialization.write-dates-as-timestamps&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now your JSON will always look like &lt;code&gt;"2023-10-27T10:00:00Z"&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. The Frontend
&lt;/h3&gt;

&lt;p&gt;The Browser knows the user's timezone.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Server sends&lt;/strong&gt; : &lt;code&gt;2023-10-27T10:00:00Z&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Browser JS&lt;/strong&gt; : &lt;code&gt;new Date('2023-10-27T10:00:00Z').toString()&lt;/code&gt; -&amp;gt; Displays &lt;code&gt;10:00 PM Tokyo Time&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Conversion happens at the &lt;strong&gt;Edge&lt;/strong&gt; (the user's screen), never in the core logic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Never&lt;/strong&gt; use &lt;code&gt;LocalDateTime.now()&lt;/code&gt; in business logic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Always&lt;/strong&gt; inject &lt;code&gt;java.time.Clock&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use&lt;/strong&gt; &lt;code&gt;Clock.fixed()&lt;/code&gt; in tests to time-travel.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Store&lt;/strong&gt; everything as UTC (&lt;code&gt;Instant&lt;/code&gt;).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Time is hard. But with &lt;code&gt;Clock&lt;/code&gt;, at least it's testable.&lt;/p&gt;

&lt;p&gt;For further reading, see the &lt;a href="https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/time/Clock.html" rel="noopener noreferrer"&gt;java.time.Clock Javadoc&lt;/a&gt;, the &lt;a href="https://docs.oracle.com/javase/tutorial/datetime/" rel="noopener noreferrer"&gt;Java Date and Time API tutorial&lt;/a&gt;, and the &lt;a href="https://docs.spring.io/spring-boot/appendix/application-properties/index.html" rel="noopener noreferrer"&gt;Spring Boot Jackson datetime configuration reference&lt;/a&gt;.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://rabinarayanpatra.com/blogs/spring-boot-testcontainers-guide" rel="noopener noreferrer"&gt;Integration Testing with Testcontainers in Spring Boot&lt;/a&gt; — Use &lt;code&gt;Clock.fixed()&lt;/code&gt; alongside Testcontainers for fully deterministic integration tests.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://rabinarayanpatra.com/blogs/java-libraries-beyond-lombok" rel="noopener noreferrer"&gt;10 Essential Java Libraries Beyond Lombok&lt;/a&gt; — More libraries that eliminate boilerplate, including testing and utility tools that complement good time handling.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Happy Coding! ⏱️&lt;/p&gt;

</description>
      <category>backend</category>
      <category>java</category>
      <category>testing</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Sanitizer-Lib is Now Live on Maven Central</title>
      <dc:creator>Rabinarayan Patra</dc:creator>
      <pubDate>Mon, 08 Dec 2025 18:30:00 +0000</pubDate>
      <link>https://dev.to/rabinarayanpatra/sanitizer-lib-is-now-live-on-maven-central-gil</link>
      <guid>https://dev.to/rabinarayanpatra/sanitizer-lib-is-now-live-on-maven-central-gil</guid>
      <description>&lt;p&gt;It's official: &lt;strong&gt;Sanitizer-Lib&lt;/strong&gt; is now available on Maven Central! 🚀&lt;/p&gt;

&lt;p&gt;A few months ago, I introduced &lt;a href="https://www.rabinarayanpatra.com/blogs/sanitizer-lib-intro" rel="noopener noreferrer"&gt;Sanitizer-Lib&lt;/a&gt;, a Java library designed to kill the boilerplate of input sanitization. No more manual &lt;code&gt;.trim()&lt;/code&gt; calls, no more scattered string manipulation logic. Just clean, declarative annotations.&lt;/p&gt;

&lt;p&gt;Since then, the feedback has been amazing. But there was one friction point: you had to use JitPack or build it locally.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Not anymore.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;As of today, you can pull &lt;strong&gt;Sanitizer-Lib&lt;/strong&gt; directly from Maven Central. It's production-ready, signed, and just a copy-paste away.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you add Sanitizer-Lib to your Maven or Gradle project?
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Maven
&lt;/h3&gt;

&lt;p&gt;Add this 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.github.rabinarayanpatra.sanitizer&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;sanitizer-spring&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;1.0.22&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;h3&gt;
  
  
  Gradle
&lt;/h3&gt;

&lt;p&gt;Add this to your &lt;code&gt;build.gradle&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nf"&gt;implementation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"io.github.rabinarayanpatra:sanitizer-spring:1.0.22"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What problems does Sanitizer-Lib solve that manual validation doesn't?
&lt;/h2&gt;

&lt;p&gt;If you missed the &lt;a href="https://www.rabinarayanpatra.com/blogs/sanitizer-lib-intro" rel="noopener noreferrer"&gt;original deep dive&lt;/a&gt;, here is the 30-second pitch:&lt;/p&gt;

&lt;p&gt;Instead of writing this validation spaghetti:&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="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;createUser&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;UserDto&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getEmail&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setEmail&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getEmail&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;trim&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;toLowerCase&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;// ... repeat for 10 other fields&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You just do 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="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;UserDto&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nd"&gt;@Sanitize&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;using&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="nc"&gt;TrimSanitizer&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="nc"&gt;LowerCaseSanitizer&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;private&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;email&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 it. Incoming requests are automatically sanitized &lt;em&gt;before&lt;/em&gt; they even hit your controller logic.&lt;/p&gt;

&lt;h3&gt;
  
  
  What changed from JitPack to Maven Central?
&lt;/h3&gt;

&lt;p&gt;Publishing to Maven Central means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No extra repository configuration&lt;/strong&gt; — Maven Central is the default repository for every Maven and Gradle project&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Signed artifacts&lt;/strong&gt; — All JARs are GPG-signed, ensuring you're getting the authentic library&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reliable availability&lt;/strong&gt; — Maven Central has 99.99% uptime, unlike JitPack which can have intermittent issues&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Better IDE support&lt;/strong&gt; — IntelliJ and Eclipse resolve Maven Central dependencies faster&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Migration from JitPack
&lt;/h3&gt;

&lt;p&gt;If you were using Sanitizer-Lib via JitPack, here's what to change:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Remove the JitPack repository from your &lt;code&gt;pom.xml&lt;/code&gt; or &lt;code&gt;build.gradle&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Update the group ID from &lt;code&gt;com.github.rabinarayanpatra&lt;/code&gt; to &lt;code&gt;io.github.rabinarayanpatra.sanitizer&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Update to the latest version (&lt;code&gt;1.0.22&lt;/code&gt;)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The API remains 100% backward compatible — no code changes required.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where can you learn more about Sanitizer-Lib?
&lt;/h2&gt;

&lt;p&gt;For a full walkthrough of features, custom sanitizers, and Spring Boot integration, check out my detailed guide:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://www.rabinarayanpatra.com/blogs/sanitizer-lib-intro" rel="noopener noreferrer"&gt;Read the Full Sanitizer-Lib Introduction&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Or star the repo on GitHub: &lt;a href="https://github.com/rabinarayanpatra/sanitizer-lib" rel="noopener noreferrer"&gt;github.com/rabinarayanpatra/sanitizer-lib&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For more information, see the &lt;a href="https://search.maven.org/artifact/io.github.rabinarayanpatra.sanitizer/sanitizer-spring" rel="noopener noreferrer"&gt;Maven Central Repository Search&lt;/a&gt; and the &lt;a href="https://central.sonatype.org/publish/publish-guide/" rel="noopener noreferrer"&gt;Sonatype OSSRH Publishing Guide&lt;/a&gt;.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://rabinarayanpatra.com/blogs/sanitizer-lib-intro" rel="noopener noreferrer"&gt;Sanitizer-Lib: The Full Introduction&lt;/a&gt; — A complete walkthrough of all features, custom sanitizers, and Spring Boot integration.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://rabinarayanpatra.com/blogs/java-libraries-beyond-lombok" rel="noopener noreferrer"&gt;10 Essential Java Libraries Beyond Lombok&lt;/a&gt; — More libraries that cut boilerplate and improve code quality alongside Sanitizer-Lib.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Happy coding!&lt;/p&gt;

</description>
      <category>java</category>
      <category>news</category>
      <category>opensource</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
