DEV Community

Cover image for grep finds the string, not the reference — Strapi's magic-string problem
PaulRichez
PaulRichez

Posted on

grep finds the string, not the reference — Strapi's magic-string problem

I renamed a Strapi service a while back and then spent the next hour chasing down all the call-sites I'd just broken. My editor didn't flag a single one. Grep found some, missed others, and buried the real hits under a pile of unrelated matches. That afternoon is basically why I ended up building the tool this post is about.

But forget the tool for a second. Try this on your own project first:

What code uses api::article.article?

Go answer it. You'll reach for grep, and grep will let you down in two different ways at the same time.

grep finds the string. It doesn't find the reference.

Let me be fair to grep first. If you search for the exact UID api::article.article, you'll catch a lot, because it's a literal string. The relation in another content type's schema turns up:

// src/api/comment/content-types/comment/schema.json
"article": { "type": "relation", "relation": "manyToOne", "target": "api::article.article" }
Enter fullscreen mode Exit fullscreen mode

So does the route handler:

{ method: 'GET', path: '/articles/latest', handler: 'api::article.article.find' }
Enter fullscreen mode Exit fullscreen mode

grep finds those. Credit where it's due. The trouble starts with the references that don't contain the literal, and there are two kinds that turn up constantly.

The first is indirection. Pull the UID into a constant — which basically every codebase does eventually — and the call-site vanishes from your search:

const ARTICLE = 'api::article.article'
strapi.service(ARTICLE).findLatest()   // grep for the UID finds the const line, never this one
Enter fullscreen mode Exit fullscreen mode

The second is the method:

strapi.service('api::article.article').findLatest()
//                                     ^^^^^^^^^^^ what calls findLatest()?
Enter fullscreen mode Exit fullscreen mode

findLatest is a needle in a haystack and grep has no clue it belongs to that service. Grep the bare name and you drown; grep the UID and you never see the method call.

And even for the hits grep does get, it can't tell you what they are — a relation, a service call, a route handler — or answer the questions you actually care about: what's unused, what's broken, what depends on this. That's a graph. grep does text.

Okay, but I'm in TypeScript. Won't the editor find them?

That was my assumption too. It doesn't, and the reason is oddly specific. Look at the type Strapi gives you:

strapi.service(uid: string): any
Enter fullscreen mode Exit fullscreen mode

It returns any. The second you type .findLatest() on an any, TypeScript checks out. It can't complete the method, it can't find its references, it can't rename it. Your "Find All References" comes back empty, and not because nothing calls findLatest — the compiler just lost the trail the moment it hit any.

The relation target from earlier? That one's even more hopeless. It lives in a .json file. There's no TypeScript in JSON to begin with, nothing to analyze, nowhere to jump.

So both of the tools you'd normally trust are blind here, for different reasons:

  • grep understands text but not meaning, so it over- and under-shoots.
  • TypeScript understands types, but Strapi's magic strings live outside the type system on purpose — any returns, JSON schemas, string handlers in route configs.

Strapi resolves all of these at runtime. Your editor resolves them never. That's the whole gap.

The fix is boring: just read the schema

Here's the thing that bugged me the most — none of this needs AI, and none of it needs codegen. The answer is already sitting in the repo. The real schema.json files, the routes, the services and controllers. Something just has to read them and build the reference graph, the way Strapi itself does at boot.

So that's what I built. DevKit for Strapi is a VS Code extension (Cursor, Windsurf, Antigravity and VSCodium too) that reads your project's actual content-types, components, services, controllers, policies, middlewares and routes, and treats the magic strings as the real code they point at. It classifies them with the TypeScript compiler API, not regex, so it's reading structure, not pattern-matching.

Autocomplete gives you the real values for wherever the cursor is:

strapi.service('api::|')   // every real content-type UID in your project
Enter fullscreen mode Exit fullscreen mode

Diagnostics underline the stuff that doesn't exist, with a "did you mean" fix:

An invalid Strapi reference underlined, with a

And the reason I started all this — Find All References that actually finds them, including the strapi.service('x').findLatest() calls and route handlers TypeScript can't see, counted right above the definition:

A

Every hit gets tagged with how it reaches the entity: a service call, a documents() call, a db.query(), a schema relation, a route handler. That "how" is the part grep will never give you, because grep doesn't know a relation target and a service call are the same reference wearing different clothes.

There's the usual go-to-definition and hover on top of that, but honestly the reference graph is the piece I use every day.

Your AI agent trips on the exact same wire

This is the part I didn't expect until I watched it happen. If you code with Claude Code, Copilot's agent mode, or Cursor, your agent hits the same wall you do. Ask it to move or rename something and it'll grep for the references — same noise, same missed any-typed calls — and then it goes ahead and rewrites code off that half-right picture. That's how an agent breaks a project while looking confident about it.

So the same engine ships as an MCP server, bundled in the extension (or npx devkit-for-strapi-mcp if you run it standalone). The agent gets real tools instead of grep: find_references and dependents for who-uses-this, get_schema for the actual field names, validate_reference to check a UID before writing it, plus list_broken_refs and the dependency graph.

An AI agent querying the project's real values through the bundled DevKit MCP server

A quick honesty check on the pitch: a capable agent can open your schema files and read them, so this isn't magic and I'm not going to pretend the model is helpless without me. What it actually buys you is narrower and real — one deterministic tool call instead of re-reading thirty schema files every session, and a reference graph that grep can't build whether it's you or the agent driving. The agent trips on the same any return and the same constant indirection you just did.

"Doesn't Strapi v5 already do this with generated types?"

I expect this one in the comments, so let's get ahead of it. Credit where it's due: Strapi v5 added a UID namespace, so when an API is typed against UID.ContentType, you get autocomplete on the UID literal in your .ts code. That's genuinely useful and I'm glad it exists.

It still doesn't close the gap, for three reasons:

  1. schema.json is JSON. Generated types can't reach relation targets, component references, or dynamic zones. It's structural — half your magic strings live in files TypeScript will never look at.
  2. Autocompleting a literal isn't finding references. TS will happily complete 'api::article.article' and still have no answer for "what uses it?" across the project, and no safe rename. That's a graph, not a type.
  3. The generated types go stale. The typed path leans on ts:generate-types, and in practice it drifts — see #26348 (types not reliably updated after you edit a content type) and #18784. DevKit reads the live schema.json, so there's no generate step to forget and nothing to go stale.

Different layer, basically. It's not "typed UIDs but nicer," it's the reference graph across JSON, route config, and any-typed calls that types structurally can't touch.

One rule I stuck to: guarantee, don't guess

If a string can't actually be proven — a variable instead of a literal, an external plugin that isn't in your workspace — DevKit says nothing rather than show you a wrong squiggle. A false positive burns trust faster than silence ever will. Unverifiable external refs get reported as external, never invented.

Try it

It's free and MIT. Works on Strapi v4 and v5 (detected per project), in JavaScript and TypeScript, no codegen, no telemetry — your code never leaves your machine.

Finding references is free. If you want the tool to actually change them for you — rename that propagates across every call-site, route handler and relation target — there's a paid tier for that. But the "stop losing fights with magic strings" part costs nothing.

If you try it and it misses a pattern, or trips on some v4/v5 quirk, tell me. That's exactly the stuff I want to hear about.


Disclosure, per DEV's guidelines: I used an AI assistant to help draft and edit this post. The tool, the code, the bugs I hit, and the opinions are mine.

Top comments (0)