Sanity CMS vs Contentful for Next.js projects: an honest comparison

When a client asks me to recommend a CMS for their Next.js project, the choice almost always comes down to Sanity CMS vs Contentful. Both are mature headless platforms with solid Next.js support, but they make very different bets on query language, pricing, and developer experience. I’ve shipped production projects on both, and the gap is more nuanced than the marketing suggests.

What you’re actually comparing

Contentful is a SaaS-first platform. You get a hosted API, a structured content model editor, and a well-documented REST/GraphQL API. Everything is managed for you — schema migrations, Studio hosting, CDN delivery. That’s the value proposition.

Sanity ships a content lake (the hosted backend) plus an open-source Studio that you own, configure, and deploy yourself. Schemas live in your repo as TypeScript files. You query with GROQ, a purpose-built query language. The Studio runs as a Next.js route or a standalone Vite app depending on how you wire it.

That distinction — schema-in-repo vs schema-in-dashboard — changes almost every downstream decision.

GROQ vs GraphQL: the real query language trade-off

This is where most developers form a strong opinion fast.

Contentful’s GraphQL API is standard and predictable. If your team already knows GraphQL, you’re productive on day one. Tooling like GraphQL Code Generator gives you typed responses with minimal config.

GROQ is Sanity’s query language and, after a week with it, I find it meaningfully more expressive for content shapes. You can dereference, filter, slice, and project in a single query without nested fragment boilerplate. Here’s a real GROQ query I use for a blog index:

// Used in: app/(blog)/blog/page.tsx
*[_type == "post" && defined(publishedAt) && !(_id in path("drafts.**"))]
  | order(publishedAt desc)[0...12] {
  _id,
  title,
  "slug": slug.current,
  publishedAt,
  "author": author->{ name, "avatar": image.asset->url },
  "categories": categories[]->{ title, "slug": slug.current },
  "lqip": coverImage.asset->metadata.lqip,
  "dimensions": coverImage.asset->metadata.dimensions
}

The equivalent in Contentful GraphQL requires separate authorCollection and categoryCollection queries or deeply nested fragments. It’s not unmanageable, but it accumulates friction on complex content models.

The downside for GROQ: it’s proprietary. New engineers need to learn it, and there’s no ecosystem of generic tooling. Sanity TypeGen partially bridges this by generating TypeScript types from your queries, but you have to run it as part of your build pipeline.

Contentful GraphQL wins on ecosystem familiarity. GROQ wins on expressiveness for relational content.

Pricing for small teams in 2026

This has shifted. As of mid-2026, Sanity’s free tier gives you 3 users, 2 non-admin users, and a 500k API CDN request per month limit with 10 GB bandwidth. Their Growth plan starts around $15/month per seat.

Contentful’s free tier is more generous on seat count (5 users) but caps content types at 48 and API calls at 1M per month on the Community plan. Their Basic plan starts around $300/month flat, which jumps hard for small teams.

For a freelance engagement with 2–4 editors and a modest content volume, Sanity is cheaper by a wide margin. For enterprise teams already on Contentful with existing contracts, switching cost outweighs the savings.

The Sanity free tier is also genuinely usable for client handoffs — I regularly leave clients on the free plan for low-traffic sites.

Next.js integration DX

Both platforms have official Next.js packages. The experience differs in meaningful ways.

Contentful’s contentful npm package is typed but ships its own SDK with a non-trivial bundle size. Fetching content in an RSC looks clean, but you lose granular control over caching because you’re going through their SDK rather than native fetch.

Sanity’s next-sanity package wraps the Sanity client and plugs directly into Next.js’s fetch cache with revalidate tags. This matters for ISR. Here’s what a cache-tagged fetch looks like:

// app/(blog)/blog/[slug]/page.tsx
import { sanityFetch } from '@/sanity/lib/fetch'
import { postBySlugQuery } from '@/sanity/lib/queries'
import type { PostBySlugQueryResult } from '@/sanity/types'

export default async function PostPage({ params }: { params: { slug: string } }) {
  const post = await sanityFetch<PostBySlugQueryResult>({
    query: postBySlugQuery,
    params: { slug: params.slug },
    tags: [`post:${params.slug}`],
  })

  if (!post) return notFound()

  return <PostLayout post={post} />
}

The tags array maps directly to on-demand revalidation from a Sanity webhook. Contentful can do tag-based revalidation too, but it requires more manual wiring through their webhook payload.

Sanity also wins on Portable Text. Contentful’s Rich Text requires @contentful/rich-text-react-renderer, which outputs relatively flat HTML. Sanity’s Portable Text lets you map block types to custom React components — useful when editors need inline callouts, embedded components, or custom image crops inside prose.

Where Contentful wins

Contentful’s content model tooling in the web UI is more approachable for non-developers. A content strategist can add a field without touching code. For teams where the CMS owner is not a developer, that matters.

Contentful also has stronger built-in localization support at the field level, a more mature roles and permissions system, and a larger library of third-party integrations (AI assistants, DAM connectors, translation workflows). If you’re building for a mid-market brand with a dedicated content ops team, Contentful’s workflow features justify the cost.

Where Sanity wins

Schema-in-repo is the right model for teams that treat content structure as part of the codebase. You get version control on your schema, environment-specific studios, and type safety from TypeGen. For a developer-led team, this is a significant productivity edge.

The Studio is also genuinely customizable. Structure Builder, custom input components, and document badges let you shape the editorial experience for your specific content model — not the other way around.

For Next.js specifically, the combination of GROQ’s join capabilities, native fetch cache integration, and Portable Text’s component mapping makes Sanity the more productive platform for complex page architectures.

How I decide on a new project

Three questions:

  1. Who owns the content model long-term? Developer-owned → Sanity. Marketing/content ops → Contentful.
  2. What’s the team size and budget? Under 5 editors on a lean budget → Sanity free tier covers it. Large team with compliance needs → Contentful’s enterprise tier might already be the choice.
  3. How complex is the content graph? Many cross-references, portable text with custom blocks, image focal points → Sanity’s tooling handles this more gracefully.

Neither platform is wrong. They serve different principals. But for Next.js projects built by a small developer-led team, Sanity’s schema-in-code model and GROQ’s query expressiveness usually win on the work that actually matters.

Sanity vs Strapi vs Payload CMS: an honest comparison for 2026

Choosing between Sanity, Strapi, and Payload is one of the questions I get most often from teams starting a greenfield Next.js project. All three are legitimate headless CMS options in 2026, but they solve meaningfully different problems. This post is a direct comparison across the dimensions that actually matter in production: pricing, developer experience, schema modelling, image handling, and how hard it is to leave.

Pricing and hosting model

This is the sharpest difference between the three.

Sanity is fully managed SaaS. You pay per seat on the Growth plan (around $15/editor/month at the time of writing) once you exceed the free tier. The CDN, the Studio, the asset pipeline — all hosted by Sanity. There’s no infrastructure to run.

Strapi is open-source and self-hosted by default. You run it on a VPS, Railway, Render, or your own Kubernetes cluster. The Community edition is free forever. Strapi Cloud exists and gives you a managed option, but most teams I’ve seen pick Strapi specifically because they want control over where data lives — data-residency requirements, GDPR, or just cost certainty at scale. If you have 50 editors, Strapi won’t invoice you $750/month for seats.

Payload is also open-source and ships as a Node package that runs inside your own project. There’s no separate Strapi-style server — Payload is your backend. It connects to MongoDB or Postgres (Postgres support matured significantly in v3) and exposes a REST and GraphQL API plus a generated Admin UI. Payload Cloud exists for managed hosting, but the local dev story requires zero external services.

Winner on cost at scale: Strapi or Payload — neither charges per-seat, and both can run on hardware you already own.

Winner for teams that don’t want to run servers: Sanity.

Developer experience and schema modelling

All three define schemas in code, but the ergonomics differ.

Sanity schemas live in TypeScript files and feed directly into Sanity Studio. The type system is mature, TypeGen generates fully-typed GROQ query results, and the Studio renders your schema as a polished editing UI without extra configuration. The constraint is that Sanity’s content lake is a proprietary document store — you don’t own the database, and your data model is shaped by Sanity’s document/field primitives.

// sanity/schemas/article.ts
import { defineType, defineField } from 'sanity'

export default defineType({
  name: 'article',
  type: 'document',
  fields: [
    defineField({ name: 'title', type: 'string', validation: r => r.required() }),
    defineField({ name: 'body', type: 'array', of: [{ type: 'block' }] }),
  ],
})

Strapi schemas are defined either through a GUI in the Content-Type Builder or by editing JSON files in src/api/<name>/content-types/. The GUI is beginner-friendly but can produce messy diffs in version control. Teams that commit to code-first schema editing in Strapi end up with a solid workflow, but it takes discipline to avoid drift between local and production schema state. Relations in Strapi map to actual SQL joins, which is useful when you need to run arbitrary Postgres queries alongside the CMS.

Payload schemas feel the most like writing a database ORM. You define collections in TypeScript and Payload generates the Admin UI, REST endpoints, and database migrations automatically. If your team already knows Drizzle or Prisma, Payload’s schema syntax will feel familiar. The tight Postgres integration means you can join CMS data with application tables in the same database — a real advantage for product teams building SaaS or e-commerce where content and business data coexist.

// payload/collections/Articles.ts
import type { CollectionConfig } from 'payload'

export const Articles: CollectionConfig = {
  slug: 'articles',
  fields: [
    { name: 'title', type: 'text', required: true },
    { name: 'body', type: 'richText' },
    { name: 'author', type: 'relationship', relationTo: 'users' },
  ],
  admin: { useAsTitle: 'title' },
}

Winner on DX for content-rich sites: Sanity — TypeGen plus GROQ plus the Studio is a complete, opinionated stack.

Winner for Postgres-native product apps: Payload — you get a CMS and an application database in one.

Editor UX

This is where Sanity has a durable lead. The Studio is the most polished editing interface of the three. Real-time collaboration, document presence, Portable Text with inline components, image hotspot editing, and a structure builder for custom navigation — all are production-grade and have been refined over years.

Strapi’s Admin UI is functional and non-technical editors can learn it quickly, but it feels like a form builder rather than a publishing tool. There’s no equivalent to Portable Text; rich text relies on a Quill or Slate integration that varies by version.

Payload’s Admin UI is impressive given how recently it was rebuilt in v3, but it’s still primarily developer-facing. For content-heavy teams where editors work daily, the gap with Sanity is real.

Winner on editor UX: Sanity, and it’s not close.

Image pipeline

Sanity’s image CDN is one of the strongest arguments for the platform. Images are stored in the content lake and served via cdn.sanity.io with on-the-fly transforms: width, height, format (WebP/AVIF), quality, crop, and hotspot-aware focal cropping. Combined with next/image and a custom loader, you get automatic format negotiation and LCP-optimised delivery with minimal setup.

// lib/sanity-image-loader.ts
export default function sanityLoader({ src, width, quality }: ImageLoaderProps) {
  return `${src}?w=${width}&q=${quality ?? 75}&auto=format&fit=crop`
}

Strapi stores uploads locally or in an S3-compatible bucket via a provider plugin. There’s no built-in image transform pipeline — you either run your own (Cloudinary plugin is common) or handle transforms at the Next.js layer. More moving parts, more configuration.

Payload handles media similarly to Strapi: uploads go to disk or cloud storage, transforms require a plugin or an external service. The @payloadcms/plugin-cloud-storage covers S3, GCS, and Azure, but image optimisation is still on you.

Winner on image pipeline: Sanity — the built-in CDN with on-the-fly transforms removes an entire category of infrastructure decisions.

Lock-in and portability

This is the honest conversation clients avoid until it’s too late.

Sanity stores content in a proprietary NDJSON document store. You can export all data via the API, and the format is readable, but your GROQ queries and schema primitives (especially Portable Text) don’t map directly to any other CMS. Migrating away is a project, not an afternoon.

Strapi and Payload both use standard SQL or MongoDB. Your data lives in tables or collections you own. Moving from Strapi to Payload (or to a raw Postgres app) is a SQL migration, not a CMS-to-CMS content export. That’s a meaningful difference if you’re building something long-lived and want optionality.

Winner on portability: Strapi or Payload — you own the database.

When each one wins

Pick Sanity when editor experience and image delivery are the priority — marketing sites, editorial platforms, content-heavy agencies. The managed CDN, Studio polish, and TypeGen workflow justify the seat cost for most content teams.

Pick Strapi when data residency, self-hosting, or seat-count economics are non-negotiable. Enterprise clients with GDPR requirements and 30+ editors will often mandate self-hosted; Strapi is the most mature option in that lane.

Pick Payload when you’re building a product app and want your CMS and application database in the same Postgres instance. Authentication, collections, and business data unified under one Node app, with a generated Admin UI included. It’s the option that most blurs the line between CMS and application framework.

Sanity CMS website cost in 2026: what founders actually pay

Budgeting for a Sanity CMS website is harder than it should be. The platform itself is free to start, which makes early quotes feel reassuring — then the final invoice lands and founders wonder where the number came from. This post breaks down every real cost driver so you can scope a project honestly before you hire anyone.

What drives sanity cms website cost more than anything else

The licence fee is almost never the problem. Sanity’s free tier covers most small sites comfortably (up to three users, generous API limits). Growth and custom plan pricing starts to matter around 10+ editors or high-traffic content APIs, but even then you are looking at a few hundred dollars a month at most — not the dominant line item.

What actually drives cost is the work required to model your content, build the editing experience your team will use every day, and connect the site to everything else your business runs on. Let me walk through each layer.

Content modelling is the architectural work done before a single page is built. A developer has to define what a “product”, a “case study”, or a “press release” looks like as structured data — what fields it has, what relationships it holds, what validation rules prevent editors from publishing broken content. A simple marketing site might need five or six document types. A content platform with tags, authors, series, and gated posts might need twenty, each with their own rules. More document types means more hours, and mistakes here are expensive to fix later.

Page count and template variety compound the modelling work. Twelve pages built from three templates costs far less than twelve pages each with a unique layout. Before you get a quote, list your pages and honestly count how many are genuinely different from each other. Agencies and freelancers price template variety, not raw page count.

The editor experience is underquoted and then complained about. Sanity Studio is highly customisable — you can build a clean, opinionated interface that guides your content team, or you can ship the default and watch editors call you weekly. A well-structured studio with filtered views, conditional fields, and sensible document ordering takes real time to build. Budget for it. It pays back in reduced support requests from your team.

Integrations are where budgets stretch

Sanity stores your content. It does not handle payments, email, search, or video — and most real products need at least one of those.

  • Stripe: Connecting a product catalogue in Sanity to Stripe for checkout adds meaningful complexity. You need to decide what lives in Sanity (marketing copy, images, variant descriptions) versus what lives in Stripe (prices, inventory, webhooks). Scoping that boundary alone is a half-day conversation. Building it is typically two to four days of development.

  • SendGrid: Triggered emails off content events — a new post published, a form submitted, a membership renewed — require route handlers that listen for Sanity webhooks and call SendGrid’s API. Straightforward in isolation, but each trigger adds test coverage and edge cases.

  • Algolia: Full-text search across a large Sanity content library almost always lands on Algolia. You need a sync pipeline that pushes content to Algolia when it changes, an index schema that matches your search UX, and a search component on the front end. Expect three to five days for a well-tuned integration.

  • Mux: Video-heavy sites — course platforms, media brands — use Mux for adaptive streaming. Uploading from Sanity Studio via a custom asset source, storing the Mux playback ID in your schema, and rendering a player with the right poster frame is around two to three days of work.

Each integration is a multiplier, not an add-on. If you want Stripe and Algolia and Mux, those do not add up linearly — shared infrastructure, authentication patterns, and error handling overlap in ways that skilled developers manage efficiently, but the work is real.

Realistic cost ranges for three common project types

Small marketing site — five to eight pages, one blog section, one or two editors, no integrations beyond a contact form and basic analytics.

Design-to-launch freelance rate: £4,000–£9,000 / $5,000–$12,000. Timeline: three to six weeks. Ongoing hosting: £20–£60/month (Vercel hobby or pro, Sanity free tier). This is the profile where Sanity’s low entry cost genuinely shines. You get a clean editorial experience and a fast, modern front end at a sensible budget.

Mid-size content platform — blog with authors and tags, gated content, newsletter integration, Algolia search, twenty-plus document types, up to ten editors.

Freelance or small agency rate: £18,000–£40,000 / $22,000–$50,000. Timeline: eight to sixteen weeks. Ongoing hosting: £100–£300/month including Sanity Growth plan, Vercel Pro, and Algolia’s starter tier. The range is wide because content modelling complexity and design fidelity vary enormously at this tier.

Complex multi-locale build — multiple languages with separate editorial workflows, market-specific pricing via Stripe, Mux video library, custom Sanity Studio plugins, five-plus integrations, fifteen-plus editors, compliance requirements.

Agency rate: £70,000–£180,000 / $85,000–$220,000. Timeline: four to eight months. Ongoing hosting and tooling: £500–£2,000/month. Internationalisation alone — routing, translation workflows, locale-specific content fallbacks — adds weeks of development that most initial scopes underestimate.

Editor training and ongoing maintenance

Training is skipped in more proposals than I can count. A Sanity Studio that took six weeks to build still needs two to four hours of structured walkthrough for your editorial team, plus documentation written for non-technical staff. Budget £400–£1,200 for this. It prevents three months of avoidable support tickets.

Maintenance is a separate question from hosting. Dependency updates, Sanity schema migrations when your content needs change, new page templates as your business grows — that work is either retained on a monthly contract (£400–£1,500/month is a common range for a freelance retainer) or quoted project by project. Neither is wrong, but know which model you are agreeing to before you sign.

How to get a quote you can actually trust

Before approaching a developer or agency, write down: how many distinct page layouts you need, which third-party tools your site must talk to, how many people will edit content, and whether you need multiple languages. That list turns a vague conversation into a scopeable brief. Developers who quote from a brief are giving you a number they can defend. Developers who quote from a thirty-minute call are guessing — and you will pay for the gap.

INP for React Apps: Profiling and Eliminating Long Tasks

INP (Interaction to Next Paint) measures how quickly your UI responds after a user interacts.
If a click, tap, or keypress is followed by a noticeable delay, you’ll feel it — and so will your users.

INP is now the key responsiveness metric in Core Web Vitals, and it’s one of the most common issues on React apps that ship too much JavaScript.

What INP actually measures (in plain terms)

When a user interacts, the browser has to:

  1. run your event handler,
  2. run any state updates and rendering work,
  3. paint the next frame.

INP captures the time from interaction to the next paint for the worst interactions users experience (within a page view).

Targets (baseline)

  • Good: ≤ 200ms
  • Needs improvement: 200–500ms
  • Poor: > 500ms

The main causes of bad INP in React apps

In most apps, INP is bad because of one or more of these:

  • Long tasks (main thread blocked for >50ms)
  • Render storms (too many components re-rendering)
  • Heavy work inside event handlers (sync parsing, sorting, filtering)
  • Third-party scripts (analytics, chat widgets, tag managers)
  • Too much JS shipped (hydration costs + runtime overhead)

You don’t “optimize INP” by tweaking one thing — you reduce main-thread work and make updates cheaper.

Step 0: Confirm you really have an INP problem

Start with field data:

  • Search Console’s Core Web Vitals report (pattern-level)
  • RUM if you have it (best)

Then use lab tools to reproduce:

  • Chrome DevTools Performance recording
  • React DevTools Profiler

Step 1: Find long tasks (your #1 enemy)

If the main thread is blocked, the browser can’t paint.

How to spot them

In a Performance recording:

  • Look for long yellow blocks (scripting).
  • Zoom into interactions and check what runs right after the input event.

If you see repeated long tasks, you’ve found your INP root cause.

Step 2: Make event handlers “light”

Event handlers should ideally:

  • update state,
  • schedule work,
  • and return quickly.

Common anti-patterns

  • Doing expensive filtering/sorting synchronously on click
  • Parsing large JSON payloads during input
  • Building huge arrays/objects during a scroll/typing event

Fix patterns

  • Precompute when possible (outside the interaction)
  • Debounce expensive work triggered by typing
  • Chunk big work into smaller pieces

Step 3: Reduce React re-render costs

Many INP problems are simply “too much renders happen per interaction”.

What I check first

  • Are we passing new objects/functions every render?
  • Are lists re-rendering on every keystroke?
  • Is global state causing whole pages to update?

Fix patterns that consistently help

  • Memoize hot components (only where it matters)
  • Use stable props (avoid {} and () => {} inline for hot paths)
  • Split state: keep “typing state” local, not global
  • Virtualize big lists/grids

The goal isn’t to “memo everything”. The goal is to stop re-rendering 200 components when the user clicks one button.

Step 4: Reduce hydration + client JS on content pages

If your page is mostly content (blog posts), you usually don’t need much JS.

Best lever

Avoid turning layout/typography into client components.

Ship interaction only where needed (search box, filters, forms), and keep everything else server-rendered.

Step 5: Defer third-party scripts (they often dominate INP)

Third-party scripts can easily:

  • add long tasks,
  • create layout thrash,
  • or block the main thread during interaction.

Practical strategy

  • Defer until after first interaction or idle
  • Load only on routes that need it
  • Remove anything you don’t use weekly

Most teams keep scripts forever. INP improves fast when you treat scripts like dependencies with a cost.

Step 6: Use a repeatable INP “playbook”

This is my default workflow:

  1. Identify a bad interaction (field data or user report).
  2. Reproduce in DevTools.
  3. Find the longest task after the input event.
  4. Reduce work in the handler.
  5. Reduce re-renders triggered by that state update.
  6. Re-test and confirm the long task is gone.

Quick checklist

  • Event handlers return quickly
  • Expensive work is deferred/chunked
  • Large lists are virtualized
  • Hot components are memoized appropriately
  • Client JS is minimized on content routes
  • Third-party scripts are deferred/audited

Why Core Web Vitals Matter (and How I Improve Them)

Core Web Vitals are Google’s real‑user performance signals for how a page feels.
They’re not just “speed scores” — they influence:

  • SEO (page experience is a ranking signal),
  • conversion rate (slow, janky pages lose users),
  • trust (fast sites feel higher quality).

If you’re building a marketing site or content platform, improving Core Web Vitals is one of the highest-ROI technical tasks you can do.

What are Core Web Vitals?

Today, the core metrics are:

  • LCP (Largest Contentful Paint): how quickly the main content becomes visible.
  • INP (Interaction to Next Paint): how responsive the page is to user input.
  • CLS (Cumulative Layout Shift): how stable the layout is while loading.

These are measured using real user data (field data), not only synthetic tests.

Target thresholds (what “good” means)

Use these as your baseline:

  • LCP: good ≤ 2.5s, needs improvement 2.5–4.0s, poor > 4.0s
  • INP: good ≤ 200ms, needs improvement 200–500ms, poor > 500ms
  • CLS: good ≤ 0.1, needs improvement 0.1–0.25, poor > 0.25

If you hit “good” consistently, you’ll usually see improved crawl efficiency, higher engagement, and better rankings over time (especially in competitive queries where all content is similar).

Why they matter for SEO (the practical view)

Google has said for years: content relevance wins. That’s still true.
But on the margin — when two pages are equally relevant — performance can be the difference.

Core Web Vitals matter most when:

  • your niche is competitive (tooling, agencies, SaaS, ecommerce),
  • users bounce quickly (landing pages, blog posts),
  • your pages are media-heavy (images, embeds, third-party scripts).

Also: good UX improves how users behave (time on site, browsing depth). Those aren’t direct ranking factors in a simple way, but they correlate strongly with sites that perform well in search.

The “field vs lab” trap

Many teams ship optimisations that look great in Lighthouse but do nothing for real users.
That’s because:

  • Lighthouse runs on a simulated device/network.
  • Core Web Vitals in Search Console come from actual users across devices, geos, and connection types.

The right workflow is:

  1. Use field data to find the real problems.
  2. Use lab tools to reproduce and fix them.
  3. Validate again in field data.

How to measure Core Web Vitals (what I use)

Field data (real users)

  • Google Search Console (Core Web Vitals report): broad view by template.
  • CrUX (Chrome UX Report): site-level + page-level trends.
  • RUM (real-user monitoring): best if you want per-route and per-release tracking.

Lab data (debugging)

  • Lighthouse: quick checks.
  • Chrome DevTools Performance: deep INP/debug.
  • WebPageTest: waterfalls, filmstrip, CDN/cache behaviour.

If you can’t measure reliably, you can’t improve reliably.

LCP: the fastest wins (and the common causes)

LCP is usually dominated by one of these:

  • a big hero image,
  • a large heading block (custom fonts),
  • a server response delay (TTFB),
  • render-blocking CSS/JS.

Fixes that consistently help LCP

  • Optimise the LCP element
    Make the hero image properly sized, compressed, and served via CDN.
  • Preload the right resources
    Fonts and the actual LCP image should load early.
  • Reduce TTFB
    Cache aggressively, avoid expensive server work on first request.
  • Avoid heavy client-side hydration
    Keep initial render simple; defer non-critical scripts.

In Next.js, the biggest LCP improvements usually come from image strategy + caching + avoiding unnecessary client components.

INP: responsiveness (the one many teams ignore)

INP measures how quickly the UI updates after an interaction (click, tap, type).
Bad INP usually comes from:

  • long tasks on the main thread,
  • expensive React re-renders,
  • heavy third-party scripts,
  • too much JS on the initial page.

Fixes that move INP

  • Reduce bundle size: ship less JS.
  • Split work: move expensive logic to the server or a worker.
  • Memoize thoughtfully: avoid re-render storms.
  • Defer third-party: load analytics/chat widgets after interaction or idle.

If your site is content-heavy (blog), INP improvements often come from simply reducing client JS.

CLS: layout stability (easy to fix, big UX win)

CLS happens when elements move after rendering.
The common causes:

  • images without dimensions,
  • fonts swapping late (FOIT/FOUT),
  • injected banners/popups,
  • components that load content without reserved space.

Fixes for CLS

  • Always reserve space for images/media.
  • Avoid late-injecting UI above content.
  • Use stable font loading strategies.
  • Skeletons should match final layout.

CLS is one of the easiest vitals to improve — and users notice immediately.

A simple checklist I apply on every site

  • Images: correct size, modern formats, CDN, don’t ship huge assets to mobile.
  • Caching: CDN + edge where possible; avoid dynamic work on every request.
  • JavaScript budget: keep initial JS minimal, defer non-critical scripts.
  • Fonts: preload only what you need, limit weights, avoid blocking.
  • Third-party: audit everything; most scripts are performance tax.

What results to expect

When you improve Core Web Vitals, the most common outcomes are:

  • better engagement (scroll depth, time on page),
  • improved conversion rate on landing pages,
  • more consistent rankings (especially where competitors are similar).

And the best part: these gains compound. Every future page you publish benefits from the same performance foundation.

Why AI Agents Love Boring Code

The most useful thing you can do for your coding agent is not a better prompt or a smarter model. It’s a more boring codebase.

This sounds like a joke. It is not.

What “boring” means

Boring code is not bad code. Boring code is code that does exactly what every other piece of code in the same situation does. It uses the same patterns, the same naming, the same error handling, the same file structure. When you have read one module, you have roughly read them all. There are no surprises, no special cases, no clever shortcuts that only the original author understood.

Boring code is what you get when a team commits to a small number of conventions and refuses to deviate from them. It is the opposite of “every engineer expressing themselves.” It is engineering as a coordinated activity rather than a series of individual performances.

Senior engineers have always quietly preferred boring code, because they spend more time reading code than writing it and they know what unboring code costs. Agents make that preference economically unavoidable.

Why agents reward this

An agent working on your codebase is doing something close to advanced pattern-matching, against everything it has been trained on and everything in the repo it has just loaded into context. The more consistent that context is, the more reliable the matching.

If every controller in your codebase validates input the same way, the agent will validate input that way without being told. If half of them use one pattern and half use another, the agent will pick one (possibly the wrong one for the file it is editing) and you will find out at code review, or worse, at runtime.

This is not a quirk of any particular model. It is what happens when you feed a pattern-matcher inconsistent patterns. The codebase is a prior. Inconsistent priors produce inconsistent outputs.

The novelty tax

Every unusual thing in your codebase is a tax the agent has to pay. Some examples of what this tax looks like in practice:

A bespoke logging wrapper that does almost-but-not-quite what the standard library does. The agent reaches for the standard one because that’s what most code uses, and the resulting logs end up in a different place from everything else.

A single service that uses a different ORM than the rest of the system, for historical reasons nobody remembers. The agent writes code in the style of the majority and quietly breaks the minority.

A clever bit of metaprogramming in one module that auto-generates handlers from annotations. The agent does not recognize the trick, adds a handler manually, and now there are two handlers for the same route.

None of these are bugs in the agent. They are bugs in the codebase, where “bug” means “places where the patterns are inconsistent enough that a competent pattern-matcher will get them wrong.” Humans hit the same problems; they just compensate by spending five minutes reading nearby files first. Agents are faster than that, which means their mistakes are also faster.

The tax compounds. A codebase with three weird patterns is annoying. A codebase with thirty is a place where agent reliability collapses and nobody can quite say why.

Standards as a contract

Coding standards have always been a contract between current and future contributors. The current author agrees to write code in a particular way; future contributors agree to read it in that way. The contract is what makes the codebase legible over time.

The list of future contributors has expanded. It now includes agents: yours, your teammates’, whichever ones your company adopts next year, and whichever ones you use yourself in two years that don’t exist yet. None of them will have been in the original design discussions. All of them will read the code the same way: as a source of patterns to extrapolate from.

A team with strong, documented conventions is offering all of those future contributors the same deal. A team without them is offering each one a guessing game.

Mechanical enforcement beats good intentions

The conventions that work are the ones that do not depend on anyone remembering them. Linters, formatters, type systems, and CI checks are how you take a convention out of the realm of “we agreed to do it this way” and into the realm of “the build fails if you don’t.”

Prettier and gofmt are uninteresting tools that have done more for codebase consistency than a thousand style guides. ESLint rules with autofix turn discussions into commits. A strong type system mechanically rules out entire categories of code that an agent might otherwise produce.

This matters for agents specifically because agents respond to feedback. An agent that runs the linter and sees an error fixes the error. An agent that produces code which conforms to the linter on the first try produces it because the patterns it learned from your codebase already point that direction. Either way, the tool is doing work that human reviewers would otherwise have to do — at human speed, against agent throughput.

Conventions on disk

The other practice worth investing in is the boring one: write your conventions down, in the repo, in a file the agent can read.

The exact filename varies (AGENTS.md, CLAUDE.md, a CONTRIBUTING.md, a top-of-tree README). What matters is that the things your team knows implicitly become explicit, in a place an agent loads into context at the start of every session. “We use functional components, not classes.” “Errors bubble up to the route handler; do not catch them in the service layer.” “Database access goes through the repository module; no raw queries.”

These are the things a new human engineer would learn from a week of code review and a few uncomfortable PRs. The agent does not get a week. It gets one file. Make sure the file says what the code already implies, so the agent’s priors line up with your reality.

Use a harness

Writing conventions down is the starting move. The next one is wrapping them in a harness — the layer that turns a static list of “we do things this way” into something the agent actively references, gets corrected by, and helps maintain.

A useful working definition: a harness is everything around the agent except the agent itself. Note that I am not saying model specifically, because there is some overlap in terminology. Some folks call Claude Code (and other comparable products) a harness. In a way it is: it’s a harness around the model. Here, I am specifically talking about an agent harness defined in the code that improves the results of the agent (this session of Claude Code).

The CLAUDE.md the agent loads. The rules and skills in your .claude/ directory. The slash commands the team has standardized on. The checks that run when the agent edits a file. The loop that turns “a reviewer caught a bad pattern” into “the next agent run won’t do that.” Agent = Model + Harness. You don’t get to pick a new model every Tuesday. You can engineer the harness every day.

If you are starting from zero, bridle is a sensible default. It’s a Claude Code plugin that scaffolds CLAUDE.md and a .claude/ directory with rules, agents, skills, and commands, and ships with slash commands for the lifecycle: /bridle:learn turns a review comment into a durable rule, /bridle:audit walks the repo looking for rule violations, /bridle:harness-health flags rules that have gone stale or coverage that has thinned. The opinionation is the point — you get to a working baseline in an afternoon instead of bikeshedding directory layouts for a sprint.

But the specific tool is not the argument. The argument is that you need a working harness — one that lives in the repo, that the agent reads on every session, that the team updates when the agent surprises them, and that closes the loop between “we caught this in review” and “the next run won’t do it.” A homegrown setup made of three markdown files and a pre-commit hook beats a sophisticated harness that nobody touches. What matters is the discipline: treat the harness as part of the codebase, version it, review changes to it, and feed it every time the agent does something you would correct in a code review.

The teams that get the most out of agents are the ones that have built this layer deliberately. The teams that get the least are still hoping that a single CLAUDE.md and a strong model will be enough. It won’t.

How this connects to the pipeline

The thread running through this whole argument is the same one that runs through Continuous Delivery and through blameless post-mortems: the system, not the individual, holds the standard.

A linter check in CI is a standard the system enforces. A type error caught at build time is a standard the system enforces. A regression test that fails when the agent reaches into the wrong layer of the stack is a standard the system enforces. None of them depend on a reviewer remembering the rule at the moment they happen to be reading the diff.

Agents are going to produce a lot of diffs. The reviewers cannot scale. The standards have to.

The leverage

If you want to get more out of your agents tomorrow, the highest-leverage move is rarely a model upgrade. It is the unglamorous work of making your codebase predictable: pick one pattern per problem, document the patterns, enforce them mechanically, and refactor the loud exceptions until they are quiet.

Boring code is a force multiplier. It always was, for humans. Agents just put a number on it.

I got tired of manual WordPress maintenance across 8 client sites – so I automated all of it

If you manage WordPress sites for clients, you know the drill.

Every month, you log into each site, click “Update All Plugins,” wait, check if anything broke, run a backup, scan for issues, repeat. For one site it’s fine. For eight sites it’s 6 hours of your life gone – every single month.

I finally got annoyed enough to fix this properly.

What manual WordPress maintenance actually costs

Here’s the math I was doing before I automated:

  • 8 client sites
  • ~45 minutes per site (login, updates, backup verification, security check, client email)
  • = 6 hours/month
  • At $65/hour = $390/month of non-billable time

That’s $4,680/year. For clicking update buttons.

The automation stack

I settled on two tools: WP-CLI (runs WordPress commands from the terminal) and SSH (connects to client servers remotely). Both are free, both are standard.

The workflow per site:

  1. SSH into client server
  2. Database backup (before touching anything)
  3. wp core update
  4. wp plugin update --all
  5. wp theme update --all
  6. Security audit
  7. Generate HTML report

Here’s a simplified version of the core script:

`ash

!/bin/bash

WP_PATH=”/var/www/html/client-site”
BACKUP_DIR=”/backups”
TIMESTAMP=$(date +”%Y-%m-%d_%H-%M”)

1. Backup first

mkdir -p “”
wp –path=”” –allow-root db export
“/db_backup_.sql”

2. Update everything

wp –path=”” –allow-root core update
wp –path=”” –allow-root plugin update –all
wp –path=”” –allow-root theme update –all

3. Flush cache

wp –path=”” –allow-root cache flush
`

Simple. But the real value is running this across all client sites from a single config file.

The config approach

I keep a clients.json file:

json
{
"clients": [
{
"name": "Client A Ltd",
"slug": "client_a",
"ssh_host": "server.clienta.com",
"ssh_user": "root",
"ssh_key": "~/.ssh/id_rsa_clienta",
"wp_path": "/var/www/html/clienta",
"active": true
}
]
}

One command runs maintenance across every active client. Add a new client – edit one JSON file.

The security audit

After updates, the script checks:

`ash

Check for exposed debug.log

if [ -f “/wp-content/debug.log” ]; then
echo “WARNING: debug.log exists – may contain sensitive data”
fi

Check wp-config.php permissions

CONFIG_PERMS=$(stat -c “%a” “/wp-config.php”)
if [[ ! “” =~ ^(400|440|600|640)$ ]]; then
echo “WARNING: wp-config.php permissions: (should be 600)”
fi

World-writable PHP files

WRITABLE=$(find “” -name “*.php” -perm /o+w 2>/dev/null | wc -l)
if [ “” -gt 0 ]; then
echo “WARNING: world-writable PHP files found”
fi
`

Each client gets flagged items in their report.

The HTML report

The script generates a per-client HTML report:


? WordPress 6.5.3 - up to date
? 12 plugins updated
? Database backup: 45 MB
? Security scan: clean
? wp-config.php permissions: 644 (should be 600)

I email this to clients monthly. It shows them the value of what I’m doing.

The uptime monitor

Separate script, runs every 10 minutes via cron:

`ash

!/bin/bash

SITES=(“https://client1.com” “https://client2.com”)
ALERT_EMAIL=”[email protected]“

for SITE in “${SITES[@]}”; do
STATUS=$(curl -s -o /dev/null -w “%{http_code}” –max-time 15 “”)
if [ “” != “200” ]; then
echo “DOWN: (HTTP )” | mail -s “SITE DOWN: ” “”
fi
done
`

Results

After running this for 6 months:

  • Monthly maintenance time: 6 hours ? ~20 minutes
  • Zero missed updates
  • Caught 2 sites with world-writable PHP files
  • One client site went down at 3am – got the alert, fixed it before they noticed

The full toolkit

I packaged everything – the bulk update script (Bash + PowerShell), security audit, uptime monitor, clients.json template, HTML report template, and maintenance checklist.

WordPress Agency Automation Bundle – use code DEVTO for 20% off.

Or use the snippets above as a starting point. Either way, stop clicking “Update All” manually eight times a month.

Questions about the implementation? Drop them in the comments.

How to detect ghost jobs in 2026 — a developer’s guide

I got tired of applying to LinkedIn postings that had been “active 4 days ago” for three months straight. Turns out I wasn’t paranoid — the data backs it up. So I built a Chrome extension to flag them, and along the way I worked out how to detect a “ghost job” from inside the browser without hitting any external API. Sharing the playbook here.

The numbers (so you know this isn’t rare)

  • Nearly 4 in 10 hiring managers admit their company has posted jobs with no real plan to fill — Resume Builder 2024 hiring-manager survey, widely reported by WSJ, NYT, and BBC throughout 2024–2025
  • 18% of recruiters say they’re actively doing it right now (same survey)
  • 40% of job seekers cite ghost-job frustration as a top complaint (Indeed 2025 candidate sentiment data)
  • Why it happens: market research, résumé pipeline collection, ATS auto-reposts of already-filled roles, recruiters who quit without removing their open reqs

If you apply to 50 jobs a week and ~40% are ghosts, you’re spending around 20 cover letters worth of effort on postings that were never going to convert. At ~12 minutes per tailored application, that’s about four hours of your week — every week — burned. Annualized, you’re losing two-and-a-half working weeks per year to listings that don’t exist.

This is a tractable problem from inside the browser. Here’s how.

What “ghost job” actually means

It’s not just “a job that didn’t reply to me.” That’s regular job-search reality. A ghost job specifically means one of these:

  1. The role isn’t open — posted to pipeline candidates, signal investors the company is growing, or placate an internal stakeholder
  2. The role was open and is now filled, but the posting is still up
  3. The role exists but the hiring manager isn’t actively reviewing — auto-rejects only

A good detector doesn’t try to read minds. It looks at artifacts of these behaviors that show up in the DOM.

The four signals I use

Signal 1: Posting age

Resume Builder’s data puts the median real posting at ~30 days before it’s either filled or pulled. Postings still active past 60 days are statistically much more likely to be ghosts.

LinkedIn obfuscates this — it’ll show “Posted 4 days ago” even when the underlying post ID has been alive for months. The trick is to parse the <time> element’s datetime attribute, not the relative human-readable text.

function getPostingAgeDays(node) {
  const timeEl = node.querySelector('time[datetime]');
  if (!timeEl) return null;
  const posted = new Date(timeEl.getAttribute('datetime'));
  return Math.floor((Date.now() - posted.getTime()) / (1000 * 60 * 60 * 24));
}

If the relative text says “Posted today” but the datetime attribute reads four months ago, that’s your first red flag. In my scoring, weight scales linearly past 30 days and hits 2x at 60.

Signal 2: Repost frequency (Levenshtein distance)

Clever recruiters know about the age signal. So they delete a stale posting and re-create it with a new job ID — same description, slightly tweaked title. The relative timestamp resets, the listing looks fresh, and the candidate pool starts over.

You can’t catch this with job IDs alone. You need content similarity.

import levenshtein from 'fast-levenshtein';

function isLikelyRepost(currentDescription, historicalDescriptions) {
  for (const past of historicalDescriptions) {
    const distance = levenshtein.get(currentDescription, past);
    const ratio = distance / Math.max(currentDescription.length, past.length);
    if (ratio < 0.05) return true; // <5% character difference = same posting
  }
  return false;
}

Threshold matters. I went with < 5% after testing on ~30 known ghost reposts I’d manually catalogued. <10% gave too many false positives (postings that legitimately got minor edits). <2% missed reposts where the recruiter changed only the location header.

Storage strategy: I keep the last 90 days of descriptions per company in chrome.storage.local with an LRU cap at 1,000 entries. The 10MB quota is plenty for that.

Signal 3: Hidden salary in disclosure-mandatory states

Since 2023, seven US states require salary-range disclosure for any role that could be performed in that state: CA, CO, NY, NJ, WA, MD, RI. (NYC has its own version, and it technically covers any job that could be performed remotely from NYC — which is most knowledge work.)

If a US-targeted posting has no salary range and the company has any operations in those states, that’s a real signal. Either:

  • They’re non-compliant (rare for large companies that have legal teams)
  • The role is targeted at non-covered states only (specific intent — worth checking)
  • The posting predates the law (which loops back to signal 1)
const DISCLOSURE_STATES = ['CA', 'CO', 'NY', 'NJ', 'WA', 'MD', 'RI'];

function flagMissingSalary(posting) {
  const hasSalary = /$d{2,3},?d{3}/.test(posting.description);
  const inDisclosureState = DISCLOSURE_STATES.some(s =>
    posting.locationHeader.includes(s)
  );
  return inDisclosureState && !hasSalary;
}

Signal 4: Company-level repost rate

If a company has posted 30 roles in the last 6 months and 18 of them have shown age + repost signals, your prior on the next role from that company should be high. This is just Bayesian sanity.

I track this per company in extension storage. After ~10 postings observed per company, the company-level ghost rate becomes a meaningful weight in the score. Below 10 observations, I don’t use it — too few samples to be anything but noise.

Putting it together

Final score is a weighted blend, capped at 100:

function ghostScore(posting, history) {
  let score = 0;
  if (posting.ageDays > 60) score += 35;
  else if (posting.ageDays > 30) score += 15;

  if (isLikelyRepost(posting.description, history.descriptions)) score += 25;
  if (flagMissingSalary(posting)) score += 15;
  if (history.companyGhostRate > 0.5) score += 20;
  if (/easy apply/i.test(posting.applyType)) score += 5; // weak signal

  return Math.min(100, score);
}

Bucket the score:

  • 0–30: Likely Real (green)
  • 31–60: Caution (yellow)
  • 61–100: Likely Ghost (red)

These thresholds are arbitrary. I picked them by hand-labelling ~200 postings and tuning until precision on the red bucket sat around 80%. There’s no ground truth here — only your tolerance for false positives.

Architecture: MV3, no servers

I wanted this to be 100% client-side. No data leaves the browser, no backend to run, no privacy policy to defend.

content script  →  reads DOM on indeed.com / linkedin.com/jobs / glassdoor.com
       ↓
background SW   →  maintains posting history + company stats in chrome.storage.local
       ↓
popup           →  reads the current job's score and shows the breakdown

The trap I fell into: MV3 service workers go dormant after ~30s of idle. If you’re keeping any state in module-level globals, it’s gone next time the SW wakes up. Everything has to live in chrome.storage.local. I burned half a day on this — the symptom was scores resetting to “no history” between sessions even though I was clearly calling the store function.

The fix:

// Bad — gone when SW goes dormant
const history = new Map();

// Good — survives dormancy
chrome.storage.local.get(['history'], ({ history = {} }) => {
  // mutate, then write back
  history[companyId] = updated;
  chrome.storage.local.set({ history });
});

Related gotcha: register your event listeners at the top level synchronously. The MV2 “register inside an init callback” pattern silently breaks on MV3 — your listener won’t be re-bound when the SW wakes from dormancy, and you’ll spend an afternoon wondering why the alarm fires once and never again.

What I’d do differently

  1. Started with two job boards, not three. Maintaining DOM parsers for Indeed, LinkedIn, and Glassdoor is brutal — they each change their layout every 6–8 weeks. If I were starting over I’d ship LinkedIn-only and let user demand drive the second board.

  2. Built the company-level signal first. Per-posting heuristics get noisy. The company-level prior is the thing that actually moves the score meaningfully on repeat offenders — and it’s also the signal that gets stronger the longer a user has the extension installed. That’s the right kind of moat.

  3. Skipped Levenshtein initially. Useful but expensive — I underestimated how often I’d hit chrome.storage.local on a single page render. For ~1,000-entry history it’s fine; if you push past that you’ll want a cheap hash-based similarity pre-filter (e.g., shingling) before paying for full Levenshtein.

The product

I shipped this as GhostJob on the Chrome Web Store:

→ https://chromewebstore.google.com/detail/ghostjob/mdjchaohgneaiflafheajamfomeccach

Free for 10 scans/day, with the full score breakdown and recent-checks history. Pro is $6/month or $59 lifetime — unlimited checks, salary insights backed by the public US Bureau of Labor Statistics API, and priority support. Currently scores Indeed, LinkedIn, and Glassdoor postings. English UI is shipping next week (5/29), and the Levenshtein repost-detection update lands the week after (6/5).

If you want to skip the read-and-build path and just use it, that link is the fastest way. If you’d rather take the four signals above and roll your own, that works too — that’s why I wrote the post. The hard part of this problem isn’t the algorithm. It’s keeping the DOM parsers alive against three companies that each rev their job-search UI on a different cadence. If you’re going to maintain that yourself, budget for it.

Question for you

What signal am I missing? “Easy Apply” without a follow-up flow is on my list but I haven’t tuned its weight enough to commit. Same with recruiter profile age and number of currently-open reqs per recruiter — both feel signal-bearing but I can’t isolate them from the company-level signal cleanly yet.

If you’ve shipped anything in this space — or just have strong opinions about what makes a posting feel like a ghost — drop it in the comments.

What do you use to filter your job search?

Alman hükümeti yüksek teknoloji hedeflerine yönelik yol haritasını açıkladı

Alman hükümeti, küresel rekabet gücünü artırmak ve teknolojik bağımsızlığı sağlamak için ‘Almanya Yüksek Teknoloji Gündemi’ kapsamında yeni bir yol haritası kabul etti. Hedefler arasında ilk ticari füzyon santrali, mRNA kanser immünoterapisi ve kuantum bilgisayar geliştirme yer alıyor. Ancak sanayi ve muhalefet planı eleştiriyor.