Before
The field definitions live in an autoloaded option and are unserialised on every request, including the login page and cron.
After
Definitions live in a file and are read when they are needed; the database copy has autoload off.
Building a custom post type is not new; ten other plugins do it. What is different here is two storage decisions. Field values go into postmeta as ordinary meta rather than a custom table, because a custom table benchmarks better and WP_Query cannot see it. Definitions go into a JSON file rather than an autoloaded option, because a site with thirty field groups unserialises a hundred kilobytes on every request, including the majority that never look at a field. The pro tier builds JSON-LD from those same values and exports a standalone PHP file for a child theme.
This is a crowded category, and "it builds post types" is not news. The question that gets asked less often is what a plugin like this costs the site on every request — and the answer comes out of two storage decisions where the common instinct is wrong in both cases.
The first: field values go into postmeta as ordinary meta, not into a custom table. A custom table benchmarks better and is useless in practice — WP_Query cannot see it, orderby => meta_value_num cannot sort it, the REST API does not expose it, and no other plugin knows to look there. And the shape matters as much as the table: a gallery stored as one serialised array is invisible to every SQL comparison, forever. Here every value is a row of its own under one key — one extra row per item, in exchange for being queryable at all.
The second: definitions go into a JSON file, not an autoloaded option — and explicitly not because "files are faster than the database", which the README itself says is false. An autoloaded option costs no extra query at all; it arrives inside the one alloptions query WordPress already makes, and for a small value the option wins. The reason is what happens when the value stops being small: a site with thirty field groups unserialises a hundred kilobytes on every request — the login page, admin-ajax, REST, cron — including the overwhelming majority that never look at a field. A copy is kept in the database with autoload off, so a read-only filesystem costs a little speed rather than every post type on the site.
The strongest claim here is the consequence of that choice. The file sits in uploads, so anything that can write there controls it — another plugin, an upload flaw elsewhere on the site, a borrowed FTP account. Its contents are therefore treated exactly as untrusted as a form submission: read with json_decode and never with include or unserialize, and every value put through the same sanitiser a submitted one gets. Without that, a label could be <img src=x onerror=…>, and WordPress prints name_admin_bar without escaping — stored script execution on every admin page. There are ceilings on the number of entries, on the lists nested inside each one, and on the product of post types and fields, because bounding the file's size bounds none of those: a 14 KB file can produce twenty thousand register_post_meta calls on init. Over the ceiling, saving is refused rather than silently truncated.
The free tier also refuses names that break the site and says what would have happened: WordPress's own names, query variables such as author and name, and anything longer than twenty characters, because the column is twenty and a longer name is silently truncated there while staying full length in PHP. The pro tier builds JSON-LD from the same values and emits nothing at all for an incomplete mapping, warning instead; and it exports a standalone PHP file for a child theme, after which the plugin can be deactivated and the post types, the meta keys and the content all stay exactly where they were.
Before
The field definitions live in an autoloaded option and are unserialised on every request, including the login page and cron.
After
Definitions live in a file and are read when they are needed; the database copy has autoload off.
Before
Galleries and checkbox values sit in one row as a serialised array, and no query can see inside them.
After
Every value is a row of its own under the same key, so meta_query and orderby actually work.
Before
You created a post type called author, got no error at all, and found out months later that its archive never opens.
After
The name is refused and the plugin explains what would have happened — before anything is created.
From the CPT & Schema menu, choose Add post type. Write the name in lowercase Latin letters; if a name would break the site, it is refused there and then, with the reason.
Choose which post types the group appears on, then add its fields. Each field type says beside itself how it is stored and what that makes sortable.
With either of the two blocks in the editor, or with a function in your template. Both read the value when the page is built, rather than from something frozen into post content.
Sites that genuinely need custom post types · catalogues and listings that have to sort and filter on a field · content sites that want structured data without an SEO plugin · developers who would rather not autoload a hundred kilobytes on every request · designers who build a site with a plugin and want to remove it afterwards · agencies handing a site over who do not want the data structure held hostage · anyone who has once created a post type called author.
No Composer, no custom database table, no external service and no jQuery. What you build stays where WordPress keeps it.
A custom table benchmarks better and is useless in practice: WP_Query cannot see it, orderby => meta_value_num cannot sort it, the REST API does not expose it, and no other plugin knows to look there. Here every value is ordinary post meta, prefixed hd_ncs_ and without a leading underscore — because hidden meta is meta the site owner cannot see or fix when something goes wrong.
A gallery stored as one serialised array is something like a:3:{i:0;s:2:"12";…} in a longtext column. No SQL comparison can see inside it, so it is invisible to every query, forever, and the only fix is a migration. Here a repeating field is several rows under one key. Numbers are stored as numbers too, so ordering puts 10 after 9 rather than before it.
Not because "files are faster than the database" — that is not true. An autoloaded option costs no extra query; it arrives inside the one alloptions query WordPress already makes, and for a small value it wins. The reason is what happens when the value stops being small: thirty field groups is a hundred kilobytes unserialised on every request, including the majority that never look at a field.
Plenty of real hosting is read-only at the filesystem level. If the file cannot be read, everything keeps working from the database copy and the Tools screen says where it is reading from; reads are slightly slower and nothing is lost. Writes are atomic — a temporary file and then a rename — so a reader never sees half a document.
Anything that can write to the uploads directory controls this file — another plugin, an upload flaw elsewhere on the site, a borrowed FTP account. So it is read with json_decode and never with include or unserialize, and every value goes through the same sanitiser a submitted form value does. Without that, a label could be <img src=x onerror=…>, and WordPress prints name_admin_bar without escaping.
Bounding the file's size bounds none of those: a 14 KB file can describe a hundred post types times two hundred fields, which is twenty thousand register_post_meta calls on init on every request. So the product is bounded too, at two thousand registrations. And saving more than a ceiling is refused rather than silently truncated — losing the customer's work while telling them it was saved is worse than refusing.
WordPress will happily accept a post type called author and then leave its archive unreachable forever. Three categories are refused, each with the reason: WordPress's own names; query variables such as author, type and name; and anything longer than twenty characters, because the database column is twenty and a longer name is truncated there while staying full length in PHP. None of them fails immediately — they come back months later as an untraceable bug.
Text, text area, number, URL, image, gallery, select, radio, checkbox and yes/no. The text area goes through wp_kses_post, so a link survives and a script does not. URLs accept http and https only. Select and radio accept only one of the declared choices. Image and gallery store the attachment ID rather than the URL — so the image survives a domain change — and the ID is verified to really be an attachment.
One shows a single field, the other lists every field on the post. Server rendering means the value is read when the page is built rather than frozen into post content; change the value tomorrow and the page changes with it. There are classic editor meta boxes as well, with a media picker built on wp.media and no jQuery.
That call is what makes a value visible to the REST API and therefore to the block editor; its absence is the single most common reason a hand-rolled meta box "does not work in Gutenberg". Each field's REST visibility follows the post type's own, and a field that should not be read publicly can be kept out of REST — with the caveat, stated on screen, that it is still in the database and an administrator can see it.
Product, Article, LocalBusiness, Event and FAQ, built from the fields you defined and injected into wp_head — no SEO plugin required. Only on a singular view, and only for a post the visitor can actually see: a password-protected or unpublished post produces nothing. The payload is encoded with JSON_HEX_TAG, so a field value cannot close the script element it is printed inside.
If a type's required properties have no value — a name for Product, a headline for Article, a name and a start date for Event, a name and an address for LocalBusiness — nothing is printed and the admin screen names the properties that are empty. Search engines treat half a Product as a quality problem, and nothing is better than half.
You download one file, paste it into the child theme's functions.php and deactivate the plugin. The post types, the taxonomies, the meta registration and every value already entered stay exactly where they were; what goes is the admin screens, the field editing boxes and the JSON-LD output. Every value is emitted through a literal writer rather than by interpolation, and the test suite runs the generated file to prove a hostile label arrives as an inert string.
A complete fa_IR translation ships with the plugin — 191 of 191 strings — and the admin loads admin-rtl.css when is_rtl(). The screens are built with real ARIA: tabs you move between with the arrow keys, labelled fieldsets for radio and checkbox groups, visible focus rings, and status colours always paired with a word. No jQuery — vanilla ES6, on this plugin's own screens only.
| Capability | Detail | Included |
|---|---|---|
| Values are ordinary post meta | No custom table, prefixed hd_ncs_ | |
| Repeating fields, one row per value | A serialised array can never become queryable | |
| Numbers stored as numbers | meta_value_num sorts them: 10 after 9 | |
| Meta keys without a leading underscore | Hidden meta is meta the owner cannot fix | |
| register_post_meta for every field | REST visibility follows the post type's own | |
| Ten field types with type-aware sanitisers | Each knows what a valid value for it looks like | |
| Attachment IDs verified | For image and gallery, so a stray number cannot point at a page | |
| Text areas through wp_kses_post | A link survives, a script does not | |
| URLs limited to http and https | Other schemes are not accepted | |
| Select and radio limited to their choices | A value outside the declared list is not stored | |
| Definitions in a JSON file inside uploads | Not in an autoloaded option | |
| A fallback copy in the database | With autoload off, for a read-only filesystem | |
| Atomic writes | Temporary file then rename; a reader never sees half a document | |
| Read with json_decode | Never with include or unserialize | |
| Re-validated on the way back out | A malformed definition is skipped, not passed to register_post_type | |
| File size and depth ceilings | Two megabytes and sixteen levels of nesting | |
| A ceiling on entries per section | Two hundred, already more than any real site | |
| A ceiling on post types times fields | Two thousand meta registrations in one request | |
| Over the ceiling, saving is refused | Rather than truncated silently and reported as saved | |
| Guard files never written through a symlink | A dangling link cannot redirect the write elsewhere | |
| The directory must resolve inside uploads | The path is resolved before it is used | |
| An unguessable directory name | Twelve random characters, because .htaccess does nothing on nginx | |
| WordPress's own names refused | post, page, attachment, wp_block and the rest | |
| Query variable names refused | author, type, name, year, order and the rest | |
| Names over twenty characters refused | The column is twenty, and longer is truncated silently | |
| A name another plugin registered is skipped | Rather than overwritten, and the reason is shown | |
| Menu icons restricted | A Dashicons name or a base64 SVG data URI | |
| Caches cleared on switch_blog | One site on a network cannot register another's post types | |
| The migration lock carries a token | So it is only ever released by the request that holds it | |
| Every REST route behind a capability check | No required arguments, so an anonymous caller meets the permission callback | |
| JSON-LD encoded with JSON_HEX_TAG | A field value cannot close the script element | |
| Incomplete mapping: a warning, no output | Rather than half a piece of structured data | |
| Generated PHP through a literal writer | A hostile label becomes a string, not code |
| Item | Minimum |
|---|---|
| WordPress | 6.2 or newer, tested to 6.8 |
| PHP | 8.0 to 8.3 — both are checked, and the plugin declines to start with an explanation rather than a fatal error |
| Uploads directory | Writable if possible; if it is not, everything keeps working from the database copy and the Tools screen says so |
| Capability | manage_options to administer the plugin, changeable through hd_ncs_manage_capability |
| Dependencies | None — no Composer, no vendor directory, no external service and no custom database table |
A site that genuinely needs custom post types and fields and will live with them for years: a catalogue, a project archive, case studies, property listings — anything you have to sort and filter on. If you need two extra fields, anything works and this plugin has little to say. Its second audience is the designer or agency who builds a site and hands it over, and would rather the data structure were not tied to a plugin forever — which is precisely what the pro tier's PHP export solves.
In a benchmark, yes. In practice, no. WP_Query cannot see a custom table, orderby => meta_value_num cannot sort it, the REST API does not expose it, and no other plugin knows to look there — so every place you want to use the value needs bespoke code. Ordinary meta is a little heavier and works everywhere. The shape matters just as much: a repeating field becomes several rows under one key, not one serialised array that no SQL comparison can see inside.
It is, which is why it is treated as an untrusted form submission. Anything that can write to the uploads directory controls the file, so: it is read with json_decode and never with include or unserialize; every value goes through the same sanitiser a form value does, because without that a label could be <img src=x onerror=…> and WordPress prints name_admin_bar without escaping; there are ceilings on the entry count, on nested lists and on the product of post types and fields, because bounding the file's size bounds none of those; and the directory and its guard files are never written through a symlink, and the directory must resolve to somewhere inside uploads.
In your uploads directory, in a folder named native-cpt-schema- followed by twelve random characters. The random part is not a secret — the file holds field names and labels, not credentials — but .htaccess does nothing on nginx, so an unguessable path is the most a plugin can do about a directory the web server insists on serving. Guard files are written alongside it.
Everything keeps working from the database copy, and the Tools screen says where it is reading from. That copy has autoload off, so it costs one separate query on the requests that actually look at a field. Reads are slightly slower; nothing is lost. If you would rather it never touched the file at all, a setting does exactly that.
Because it fell into one of three categories that break a site. WordPress's own names, such as post, page and attachment: registering over them raises no error and makes the built-in type behave strangely. Query variables such as author, type, name and year: a post type with one of those names makes its own archive unreachable. And names longer than twenty characters: WordPress stores the name in a column that short, so it is truncated in the database while staying full length in PHP. All three start without an error and come back months later as an untraceable bug, so the plugin gives the reason instead of accepting them.
Nothing. The posts stay in the database and reappear the moment the post type is defined again — by this plugin, by the exported PHP file, or by anything else. The same is true when the plugin is uninstalled: content is never deleted.
Yes, and that is the point of the storage design. Numbers are stored as numbers so meta_value_num sorts them correctly — 10 after 9, not before it. A repeating field is several rows under one key, so meta_query can see each value individually; in one serialised array it never could.
Yes. The values are ordinary post meta, which is what every builder's dynamic data feature reads. Nothing here is specific to the block editor; the blocks and the classic meta boxes are two ways of showing a value among several.
One ZIP with everything in it: post types and taxonomies across the full WordPress parameter surface, the refusal of dangerous names, ten field types with queryable storage, register_post_meta and two server-rendered blocks — plus JSON-LD output for Product, Article, LocalBusiness, Event and FAQ, and the standalone PHP export for a child theme. Nothing downloads separately and nothing sits behind a check.
The admin screens, the field editing boxes on the post screen, and the JSON-LD output. What stays: the post types, the taxonomies, the meta registration and every value already entered. So you can build the site with the plugin, drop the export into a child theme and remove the plugin; the data structure is not held hostage. The generated code is never executed by the plugin itself, and every value is emitted through a literal writer so a label that looks like code arrives as a string.
The default is that nothing is removed. Somebody uninstalling a plugin is often trying another one or moving hosts, and a plugin that takes their definitions and field values with it on the way out has destroyed hours of work nobody asked it to. There is a setting you have to find and switch on deliberately; with it on, deleting the plugin removes the definitions, the settings and the field values. Even then, the posts themselves are never deleted.
Plugin bug fixes, installation and setup guidance, help designing post types and field groups, and help with schema mappings. Writing bespoke template code to display values, optimising the site itself, and problems caused by other themes or plugins fall outside support and are agreed separately.