Designing a data model in Strapi usually means clicking through the Admin Panel, adding fields one at a time, configuring relations by hand, and hoping you remembered that singularName needs to be kebab-case. For a project with three or four interconnected Collection Type schemas, that process eats time you could spend on actual feature work.
There's a faster path: describe your data model in natural language, let Claude generate valid schema.json files, drop them into your Strapi project directory, and restart the server. The schemas show up in the Content-Type Builder as if you'd built them by hand.
This guide walks through the full workflow using a developer blog platform as the project example. You'll scaffold interconnected Collection Type schemas for articles, authors, and categories, complete with relations, components, media fields, and UID (Unique Identifier) slugs.
In brief:
- Strapi Content-Types are defined by
schema.jsonfiles that you can generate outside the Admin Panel and drop into your project directory - A well-structured system prompt turns Claude into a reliable Strapi schema generator that handles relations, components, and media fields correctly
- Adding a Zod validation layer catches malformed output before it reaches your codebase
- The complete pipeline, prompt → generate → validate → write to disk, takes minutes instead of manual field-by-field configuration
Prerequisites
Here's what this guide assumes you have set up:
- Node.js 18+ and npm installed
- Strapi 5 project initialized (or willingness to create one). Strapi is a headless CMS that exposes your content through REST and GraphQL APIs.
- Anthropic API key with access to Anthropic's available Claude Opus models
- TypeScript basics (the script examples use TS, but the concepts apply to plain JS)
- Working familiarity with Strapi's Content Manager
This guide targets Strapi 5 specifically because it introduced significant changes to the Content-Type schema format compared to v4, including a new Document Service API, different publication state handling, and reserved attribute names. All schemas follow the v5 model format and won't work with Strapi v4 without modification.
If you're working with an existing Strapi project, back up your src/api/ directory before running the generation script.
If you don't have a Strapi project yet:
npx create-strapi@latest dev-blog --quickstartTo get an Anthropic API key, sign up at Anthropic Console and create an API key for your workspace:
export ANTHROPIC_API_KEY=your-key-here
npm install @anthropic-ai/sdkUnderstand the schema.json Format
Every Strapi Content-Type lives as a schema.json file in a specific directory structure. When you use the Content-Type Builder UI, Strapi writes these files for you. But nothing stops you from creating them directly.
The file path follows this pattern:
./src/api/[api-name]/content-types/[content-type-name]/schema.jsonHere's a minimal example of what a valid schema.json looks like:
{
"kind": "collectionType",
"collectionName": "articles",
"info": {
"singularName": "article",
"pluralName": "articles",
"displayName": "Article",
"description": "Blog articles"
},
"options": {
"draftAndPublish": true
},
"pluginOptions": {},
"attributes": {
"title": { "type": "string" },
"content": { "type": "richtext" },
"slug": { "type": "uid" }
}
}Four sections matter here:
| Section | Purpose |
|---|---|
kind + collectionName | Determines whether this is a Collection Type or Single Type, plus the database table name |
info | Display name and the kebab-case singular/plural names that drive API route generation |
options | Behavioral settings like Draft & Publish |
attributes | Every field definition with its type and configuration |
A critical detail: singularName and pluralName must be kebab-case. Get this wrong and you'll hit routing errors that are surprisingly hard to diagnose.
Strapi supports attribute types across several categories: string types (string, text, richtext, enumeration, email, password, uid), numeric types (integer, decimal, float, biginteger), date/time types (date, time, datetime), and Strapi-specific types (media, relation, customField, component, dynamiczone). Choosing the right type matters more than it might seem at first glance.
The uid type deserves special attention. When you set a targetField, Strapi auto-generates a URL-friendly slug from that field's value — essential for clean API endpoints and SEO-friendly URLs. The draftAndPublish: true option enables Strapi's built-in content workflow where entries exist in either a draft or published state. For Content-Types like authors or categories where every entry should be immediately available, setting draftAndPublish: false simplifies things.
When Strapi starts, it reads all schema.json files and synchronizes the database schema automatically. It also generates REST routes based on the singularName and pluralName values. For example, an article Content-Type automatically gets /api/articles for listing and /api/articles/:documentId for single entries. If you install the GraphQL plugin, Strapi generates a full GraphQL schema from the same files.
Set Up the Claude System Prompt
The quality of Claude's output depends almost entirely on how precisely you define the expected format. A contract-style system prompt that specifies every structural requirement produces schemas you can use without editing. This follows the structured outputs approach:
const STRAPI_SCHEMA_SYSTEM_PROMPT = `
ROLE: You are a schema generation API that produces valid JSON schemas for Strapi CMS v5.
TASK: Generate Strapi content type schema.json files for the described entities.
CONSTRAINTS:
- Output ONLY valid JSON. No markdown code fences, no explanatory text.
- Strapi content-type schemas commonly include top-level fields such as kind, collectionName, info, options, pluginOptions, and attributes
- singularName and pluralName should be kebab-case
- Relation targets typically use the format "api::api-name.content-type-name"
- Component references must use format "category.componentName"
- When generating multiple schemas in Strapi v5, keep each content type schema in its own schema.json file under its dedicated content-type folder
ATTRIBUTE TYPES AVAILABLE:
- String: string, text, richtext, enumeration, email, password, uid
- Number: integer, biginteger, float, decimal
- Date/Time: date, time, datetime, timestamp
- Generic: boolean, json
- Strapi-Specific: media, relation, customField, component, dynamiczone
RELATION FORMAT:
- oneToOne: { "type": "relation", "relation": "oneToOne", "target": "api::x.x" }
- oneToMany: { "type": "relation", "relation": "oneToMany", "target": "api::x.x", "mappedBy": "fieldName" }
- manyToOne: { "type": "relation", "relation": "manyToOne", "target": "api::x.x", "inversedBy": "fieldName" }
- manyToMany: { "type": "relation", "relation": "manyToMany", "target": "api::x.x", "inversedBy": "fieldName" }
MEDIA FORMAT:
{ "type": "media", "multiple": false, "allowedTypes": ["images"] }
COMPONENT FORMAT:
{ "type": "component", "repeatable": true|false, "component": "category.name" }
OUTPUT FORMAT FOR SINGLE SCHEMA:
{ "kind": "...", "collectionName": "...", "info": {...}, "options": {...}, "pluginOptions": {}, "attributes": {...} }
If any field type or relation is ambiguous, include a "_clarification_needed" key in the attribute.
`;The key parts that matter most: "Output ONLY valid JSON" prevents Claude from wrapping JSON in markdown code fences that would break JSON.parse().
The relation target format api::api-name.content-type-name prevents the most common error — without it, Claude might produce "target": "author" or "target": "api::author", both invalid. Listing valid attribute types reduces the chance Claude outputs unsupported types like "type": "url". The _clarification_needed fallback flags ambiguous attributes instead of silently guessing wrong.
Generate Blog Schemas from Plain English
You need three Collection Type schemas for the developer blog. Here's the prompt that describes the full data model:
Generate three Strapi collection type schemas for a developer blog:
1. "article": title (text, required), description (short text), content (richtext),
cover (single image media), slug (uid derived from title),
category (manyToMany relation to category), author (manyToOne relation to author).
Draft & Publish enabled.
2. "author": name (string, required), avatar (single image media), email (string),
bio (richtext), social_links (repeatable component named "shared.social-link"
with fields: name (string) and url (string)). No Draft & Publish.
3. "category": name (string, required), description (string),
slug (uid derived from name). No Draft & Publish.
Return a JSON object with keys "article", "author", "category", plus a
"components" key containing any component schemas needed.This model mirrors the Strapi blog guide and exercises several features, including manyToMany and manyToOne relations and media fields.
Here's the TypeScript code that sends this prompt to Claude:
import Anthropic from '@anthropic-ai/sdk';
const client = new Anthropic();
async function generateSchemas(description: string): Promise<Record<string, any>> {
const response = await client.messages.create({
model: 'claude-opus-4-5',
max_tokens: 4096,
system: STRAPI_SCHEMA_SYSTEM_PROMPT,
messages: [{ role: 'user', content: description }],
});
const text = response.content[0].type === 'text'
? response.content[0].text
: '';
return JSON.parse(text);
}A few implementation details worth noticing: max_tokens: 4096 gives enough headroom for three Content-Type schemas plus a component definition, and claude-opus-4-5 is used here because schema generation depends on precise adherence to a structural contract. The response can be parsed directly with JSON.parse(text), which is why the system prompt strictly requests raw JSON output.
Claude can return structured JSON matching a provided schema, including component definitions when explicitly specified. The article schema should look something like this:
{
"kind": "collectionType",
"collectionName": "articles",
"info": {
"singularName": "article",
"pluralName": "articles",
"displayName": "Article",
"description": "Blog articles for the developer blog"
},
"options": {
"draftAndPublish": true
},
"pluginOptions": {},
"attributes": {
"title": { "type": "text", "required": true },
"description": { "type": "string" },
"content": { "type": "richtext" },
"cover": {
"type": "media",
"multiple": false,
"allowedTypes": ["images"]
},
"slug": { "type": "uid", "targetField": "title" },
"category": {
"type": "relation",
"relation": "manyToMany",
"target": "api::category.category",
"inversedBy": "articles"
},
"author": {
"type": "relation",
"relation": "manyToOne",
"target": "api::author.author",
"inversedBy": "articles"
}
}
}The title field uses text for longer input, description uses string for a compact single-line field, and content uses richtext for the full Markdown editor.
The author schema includes the inverse side of the article relation, plus the repeatable component for social links:
{
"kind": "collectionType",
"collectionName": "authors",
"info": {
"singularName": "author",
"pluralName": "authors",
"displayName": "Author",
"description": "Blog post authors"
},
"options": {
"draftAndPublish": false
},
"pluginOptions": {},
"attributes": {
"name": { "type": "string", "required": true },
"avatar": {
"type": "media",
"multiple": false,
"allowedTypes": ["images"]
},
"email": { "type": "string" },
"bio": { "type": "richtext" },
"social_links": {
"type": "component",
"repeatable": true,
"component": "shared.social-link"
},
"articles": {
"type": "relation",
"relation": "oneToMany",
"target": "api::article.article",
"mappedBy": "author"
}
}
}The category schema is the simplest, with a manyToMany back-reference to articles:
{
"kind": "collectionType",
"collectionName": "categories",
"info": {
"singularName": "category",
"pluralName": "categories",
"displayName": "Category",
"description": "Article categories"
},
"options": {
"draftAndPublish": false
},
"pluginOptions": {},
"attributes": {
"name": { "type": "string", "required": true },
"description": { "type": "string" },
"slug": { "type": "uid", "targetField": "name" },
"articles": {
"type": "relation",
"relation": "manyToMany",
"target": "api::article.article",
"inversedBy": "category"
}
}
}Component schemas are simpler than Content-Type schemas — they omit the kind field, skip the options block, and use a components_ prefix for collectionName. The component reference in Content-Types uses dot notation (shared.social-link) mapping to the filesystem path src/components/shared/social-link.json:
{
"collectionName": "components_shared_social_links",
"info": {
"displayName": "Social Link",
"icon": "link"
},
"attributes": {
"name": { "type": "string", "required": true },
"url": { "type": "string", "required": true }
}
}Pay close attention to how bidirectional relations work. The article schema declares "author": { "relation": "manyToOne", "target": "api::author.author" } — article owns the foreign key. The author schema declares the inverse: "articles": { "relation": "oneToMany", "mappedBy": "author" }.
These must use inversedBy on the owning side and mappedBy on the inverse side. This is where manual configuration in the Admin Panel is most error-prone, and exactly where having the AI handle the boilerplate pays off.
Validate Before You Write
LLM output isn't always valid, even with a tight system prompt. A missing pluralName or other schema metadata can cause Strapi to fail on restart with a low-detail error. A misspelled relation type or an invalid kind value can be equally hard to diagnose. Adding a Zod validation layer catches these issues before they reach your filesystem:
import { z } from 'zod';
const StrapiAttributeSchema = z.union([
z.object({ type: z.literal('string') }).passthrough(),
z.object({ type: z.literal('text') }).passthrough(),
z.object({ type: z.literal('richtext') }).passthrough(),
z.object({ type: z.literal('email') }).passthrough(),
z.object({ type: z.literal('uid') }).passthrough(),
z.object({ type: z.literal('integer') }).passthrough(),
z.object({ type: z.literal('decimal') }).passthrough(),
z.object({ type: z.literal('float') }).passthrough(),
z.object({ type: z.literal('boolean') }).passthrough(),
z.object({ type: z.literal('date') }).passthrough(),
z.object({ type: z.literal('datetime') }).passthrough(),
z.object({ type: z.literal('json') }).passthrough(),
z.object({ type: z.literal('enumeration'), enum: z.array(z.string()) }).passthrough(),
z.object({ type: z.literal('media'), multiple: z.boolean(), allowedTypes: z.array(z.string()) }).passthrough(),
z.object({
type: z.literal('relation'),
relation: z.enum(['oneToOne', 'oneToMany', 'manyToOne', 'manyToMany']),
target: z.string().regex(/^(api|plugin)::/),
}).passthrough(),
z.object({ type: z.literal('component'), repeatable: z.boolean(), component: z.string() }).passthrough(),
z.object({ type: z.literal('dynamiczone'), components: z.array(z.string()) }).passthrough(),
]);
const StrapiSchemaValidator = z.object({
kind: z.enum(['collectionType', 'singleType']),
collectionName: z.string(),
info: z.object({
singularName: z.string().regex(/^[a-z][a-z0-9-]*$/),
pluralName: z.string().regex(/^[a-z][a-z0-9-]*$/),
displayName: z.string(),
}),
options: z.object({ draftAndPublish: z.boolean() }),
pluginOptions: z.object({}).passthrough(),
attributes: z.record(StrapiAttributeSchema),
});The .passthrough() calls allow optional extra attribute properties like required, minLength, and private to flow through without blocking validation. The kebab-case regex on singularName and pluralName catches a common issue — Strapi uses _.kebabCase internally for uniqueness checks, and non-kebab names cause routing failures. The relation target regex ensures the api:: or plugin:: prefix format matches official Strapi examples.
If Claude produces an invalid schema, you know immediately, instead of after a confusing restart failure.
Here's how to combine generation and validation with automatic retries:
async function generateValidatedSchema(
description: string,
maxRetries = 3
): Promise<Record<string, any>> {
let currentPrompt = description;
for (let attempt = 0; attempt < maxRetries; attempt++) {
const schemas = await generateSchemas(currentPrompt);
const errors: string[] = [];
for (const [name, schema] of Object.entries(schemas)) {
if (name === 'components') continue;
const result = StrapiSchemaValidator.safeParse(schema);
if (!result.success) {
errors.push(`${name}: ${JSON.stringify(result.error.issues)}`);
}
}
if (errors.length === 0) return schemas;
currentPrompt = `Previous attempt had validation errors:\n${errors.join('\n')}\n\nOriginal request: ${description}`;
}
throw new Error('Schema generation failed after max retries');
}This feeds validation errors back into the prompt on retry. Common failures include hyphenated relation values like "many-to-one" instead of camelCase "manyToOne", and omitting the full api:: target format. In practice, the first attempt succeeds most of the time with the system prompt above.
Write Schemas and Iterate on the Data Model
The final step is writing validated schemas to the correct directory structure:
import fs from 'fs/promises';
import path from 'path';
async function writeSchemas(
projectRoot: string,
schemas: Record<string, any>
) {
for (const [name, schema] of Object.entries(schemas)) {
if (name === 'components') {
for (const [compKey, compSchema] of Object.entries(
schema as Record<string, any>
)) {
const [category, compName] = compKey.split('.');
const compDir = path.join(projectRoot, 'src/components', category);
await fs.mkdir(compDir, { recursive: true });
await fs.writeFile(
path.join(compDir, `${compName}.json`),
JSON.stringify(compSchema, null, 2)
);
console.log(`✅ Component written: src/components/${category}/${compName}.json`);
}
continue;
}
const dir = path.join(projectRoot, 'src/api', name, 'content-types', name);
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(
path.join(dir, 'schema.json'),
JSON.stringify(schema, null, 2)
);
console.log(`✅ Schema written: src/api/${name}/content-types/${name}/schema.json`);
}
console.log('\n⚠️ Restart Strapi for changes to take effect:');
console.log(' npm run develop');
}Components follow a different path structure: ./src/components/[category]/[component-name].json. The social-link component lands in ./src/components/shared/social-link.json.
After running the script, your project tree should look like this:
src/
├── api/
│ ├── article/
│ │ └── content-types/
│ │ └── article/
│ │ └── schema.json
│ ├── author/
│ │ └── content-types/
│ │ └── author/
│ │ └── schema.json
│ └── category/
│ └── content-types/
│ └── category/
│ └── schema.json
└── components/
└── shared/
└── social-link.jsonRun npm run develop and Strapi picks up the new schemas. If you're using TypeScript, run npm run strapi ts:generate-types to get generated types matching your schemas.
Iterating When Requirements Change
The real power shows up when requirements change. Say you need to add a tags field to articles with a manyToMany relation to a new tag Collection Type. Instead of clicking through the Admin Panel, update your prompt:
Add a "tag" collection type with: name (string, required, unique), slug (uid from name).
No Draft & Publish.
Also update the "article" schema to add a "tags" field as a manyToMany relation to tag.Run the pipeline again, validate, write, and restart. The generated tag schema would look like this:
{
"kind": "collectionType",
"collectionName": "tags",
"info": {
"singularName": "tag",
"pluralName": "tags",
"displayName": "Tag"
},
"options": {
"draftAndPublish": false
},
"pluginOptions": {},
"attributes": {
"name": { "type": "string", "required": true, "unique": true },
"slug": { "type": "uid", "targetField": "name" },
"articles": {
"type": "relation",
"relation": "manyToMany",
"target": "api::article.article",
"mappedBy": "tags"
}
}
}And the article schema gains a new attribute:
"tags": {
"type": "relation",
"relation": "manyToMany",
"target": "api::tag.tag",
"inversedBy": "articles"
}This is where the approach outperforms manual configuration — managing interconnected relations across multiple Content-Types by hand is tedious and error-prone.
A few safety practices: back up existing schema.json files before overwriting, diff the changes to review what's being modified, and test in a development environment before applying to production. For teams already using Strapi, the built-in AI Content Type Builder offers another option for schema generation. The custom pipeline approach here gives you version-controlled schemas and CI/CD integration.
Troubleshooting Common Issues
Strapi crashes on restart with a vague model error. Check that singularName and pluralName are kebab-case and that collectionName doesn't conflict with reserved names.
Relations don't appear in the Admin Panel. Verify both sides of the relation exist. A manyToOne on article.author needs a corresponding oneToMany on author.articles with mappedBy: "author", while the manyToOne side uses inversedBy: "articles".
Claude returns markdown-wrapped JSON. Strip the response before parsing: text.replace(/```json?\n?|\n?```/g, '').trim().
Component not found error. The component reference (e.g., "shared.social-link") must match the file path exactly: src/components/shared/social-link.json. Dot-separated in schema, slash-separated on disk.
Database migration conflicts after schema changes. If you change a field type (e.g., string to integer), Strapi may fail because the existing column doesn't match. During development, delete the .tmp directory to start fresh. In production, you'd need a proper migration strategy.
TypeScript type errors after adding new schemas. Your existing TypeScript code may show type errors because the generated interfaces are stale. Run npm run strapi ts:generate-types to regenerate type definitions matching your updated schemas.
Verify the Schemas in the Admin Panel
After writing schema files, restart Strapi with npm run develop. Open the Admin Panel at http://localhost:1337/admin and navigate to the Content-Type Builder. You should see Article, Author, and Category listed under "Collection Types."
Verify the Article fields: author should display as a manyToOne relation targeting Author, category as manyToMany targeting Category, slug as a UID linked to title, and cover as a single-image media field. Check that the Author's social links appear as a repeatable component with name and url sub-fields. Confirm Draft & Publish is enabled for Article and disabled for Author and Category.
Before querying the API, configure permissions at Settings → Users & Permissions → Roles → Public, enabling find and findOne for each Content-Type you want to expose. After saving, test the API by hitting http://localhost:1337/api/articles in your browser or with curl. You should get a JSON response — usually an empty data array until you create entries.
To create test content, head to the Content Manager, select a Content-Type, and click "Create new entry." Start with authors and categories first, since articles reference them through relations. Once you've created a few entries, query the API again with ?populate=* to confirm that relations and components are returned correctly in the response payload.
Where to Go from Here
This pipeline covers the scaffolding phase. From here, you can add lifecycle hooks alongside your generated schemas in lifecycles.js files for custom business logic on create, update, or delete. You can extend the script to generate Dynamic Zones for page-builder patterns where content editors need flexible component arrangements.
You can also connect your frontend framework (Next.js, Nuxt, Astro) using the auto-generated REST or GraphQL endpoints, explore the REST API docs for advanced querying with filters, sorting, pagination, and field selection, or browse the plugin marketplace for additional functionality like sitemap generation or internationalization.
The developer blog example exercises the most common Strapi patterns: multiple Collection Types with cross-references, reusable components, media fields, and UID slugs. For more complex models — an e-commerce catalog or a recipe platform — the same pipeline applies. Describe what you need in plain English, let Claude generate the JSON, validate it, and write it to disk.
The core takeaway is that the feedback loop between a natural language description and a working Strapi schema collapses what used to be a manual, error-prone process into something fast and repeatable. As your data models grow more complex — with more Content-Types, deeper relation graphs, and more components — the value of structured prompting and automated validation increases proportionally.
Get Started in Minutes
npx create-strapi-app@latest in your terminal and follow our Quick Start Guide to build your first Strapi project.Theodore is a Technical Writer and a full-stack software developer. He loves writing technical articles, building solutions, and sharing his expertise.