shop-create-content-type
Create custom content types and records on this multi-tenant content platform via the shop MCP server — Strapi-like dynamic types (houses, menu items, projects, events, team members…) that are EAV-backed with NO new tables, rendered on the storefront via the `content` Liquid Drop and a data-driven url_pattern router. Use when the user wants a non-product content collection, a directory/listing site, or to add records of a custom type. ALWAYS study the design first, then PROPOSE a content-model plan (which content types + which fields in each + url_pattern) and have the user confirm/choose before creating anything.
Create dynamic content types + records through the shop MCP (create_content_type, create_content_record, list_content_types, list_content_records, set_commerce_enabled). This is the data-model skill; render the records with shop-build-section and wire a whole content SITE with shop-content-site.
Read
shop-core-rulesfirst — §A (propose + confirm before creating), §B (model FROM the design), §C (re-host record images), §D (verify), §E (links). Requireswebstore:write;set_commerce_enabledneedssettings:write.
Content types are this platform's Strapi-style dynamic content engine — a merchant-defined data model (types + typed fields) for anything that isn't a product (houses, menu items, projects, events, articles). EAV-backed, NO new tables. You model it and fill it.
Not just for content sites — product SHOPS use them too. A commerce store often needs a custom structured listing alongside its catalog: lookbooks/editorials, stockists / store-locator, size-or-care guides, recipes, an ingredient/material library, press. When building/redesigning a shop, proactively consider whether a content type fits and ASK — don't assume "it's a shop, so no content types".
When to use a content type vs a Liquid {% schema %} block (decide FIRST)
A content type is heavier (a data model, records, a list+detail route). Default to section {% schema %} blocks for everything else.
Use a CONTENT TYPE when ALL hold:
- High / growing volume — many records the merchant keeps adding (properties, dishes, events, a real article archive), not a fixed handful.
- It's a primary feature — the thing the site is about / its main listing.
- Each record needs its own detail page (a
url_patternroute) and/or structured typed fields you filter & list.
Use a Liquid {% schema %} section (blocks) — NOT a content type — when:
- A small, fixed set (a few testimonials, 3–6 features, a team of 5, FAQ items, "how it works" steps, partner logos, a hero).
- Secondary/decorative chrome, not a growing data collection. No per-item URL/detail page needed.
Rule of thumb: "Lots of these, merchant keeps adding them, as a core feature?" → content type. "A handful of editable items inside a section?" → schema blocks. When unsure, prefer blocks.
0. Workflow — model FROM the design FIRST, then build visuals
The #1 mistake is jumping to layout before there's a data model, then hard-coding content into Liquid. Do these IN ORDER:
- Study the design and do the core §B extraction — inventory every repeating/structured block (cards, listings, grids, detail pages, directories).
- Derive a candidate content-MODEL PLAN from the design. For each repeating thing ask "what is the underlying entity, and what fields does each card + detail need?", reading them off the design. A property card showing photo + price + beds + location + status ⇒ a
housestype withgallery,price(number),bedrooms(integer),location(text),status(dropdown),description(richtext). Classify each candidate: real content type vs section block vs built-in blog/pages/menu. - PROPOSE the plan and let the user CHOOSE — required gate (core §A, §1 below).
- Define the approved type(s) + url_pattern (§2) and create real published records with re-hosted images (§3).
- THEN design the visuals (
shop-build-section): list + detail templates that READcontent.<type>/recordsand bind each element to a real field — never hard-code content into markup. - Verify (§5): list + detail render with real data + images, links resolve.
1. Analyze the design → PROPOSE the content model → let the user choose (required gate)
Never silently invent the schema, and never ask a blank "what fields?" either. YOU propose a concrete plan derived from the design, then the user edits/approves. Field-type vocabulary: text, richtext, number, integer, boolean, date, image, gallery, dropdown, multiselect, url.
Present a proposal like this, then WAIT for explicit approval:
Based on the design, I propose these content types:
- Properties (
houses, detail at/properties/:slug) — the main listing. Fields:gallery(gallery),price(number, required),bedrooms(integer),bathrooms(integer),location(text),status(dropdown: For sale / Sold / Pending),description(richtext).- Agents (
agents,/agents/:slug) — staff directory. Fields:photo(image),role(text),phone(text),bio(richtext).Modeled WITHOUT a content type (lighter): testimonials & "why us" → section
{% schema %}blocks; news → the built-in blog; About/Contact → built-in pages; nav → the menu.Want me to add/remove a type, rename anything, or change fields? And how many sample records each?
For each type cover: label + code (machine, lowercase_underscore), the fields (label, type, required?, options), the url_pattern (e.g. /properties/:slug; defaults to /<code>/:slug), and how many sample records. State up front whether it's a content-only site (→ set_commerce_enabled false, the shop-content-site workflow). Let the user drive the final shape; only build approved types.
⛔ NEVER use a code that collides with a built-in storefront route — the detail page is
/<code>/:slug, so a reserved code is shadowed by the platform's own route and every record 404s. Reserved (rejected by the server):products,collections,pages,blogs,cart,checkout,search,account(+ singular variants). This bites content stores selling product-like items: a product-like listing must NOT be codedproducts— use a descriptive code (items,san_pham,catalog) or the specific noun (mushrooms,dishes). The label/title can still use the site language's word for it; only the code must avoid the reserved words.
2. Create the type
create_content_type {
label: "Houses", code: "houses", singular: "house",
url_pattern: "/properties/:slug",
fields: [
{ label: "Price", type: "number", required: true },
{ label: "Bedrooms", type: "integer" },
{ label: "Location", type: "text" },
{ label: "Status", type: "dropdown", options: ["For sale","Sold","Pending"] },
{ label: "Gallery", type: "gallery" },
{ label: "Description", type: "richtext" }
]
}
EAV-backed, no migrations. Field codes derive from labels unless you pass code.
Gotcha: create the type, THEN create its records in a FOLLOWING step. Content-type metadata is cached per-process, so creating a record in the same rapid batch can fail with
content type "x" not found. Separate calls work fine.
3. Create records
create_content_record {
type_code: "houses", title: "Oceanview Manor", status: "published",
values: { "price": "1250000", "bedrooms": 4, "location": "Da Nang",
"status": "For sale", "gallery": ["https://.../1.jpg","https://.../2.jpg"] }
}
valuesis keyed by field code; image/gallery fields take URLs (re-host first per core §C —upload_image { url, folder:"content" }, put the returned store URL invalues). Every record the design shows with a photo MUST get one (blank cards = §D fail).- Image/gallery rendering is wired (gallery JSON-string coerced to an array): single →
{{ record.<imagefield> }}; gallery →{% for img in record.<galleryfield> %}<img src="{{ img }}">{% endfor %}. - Omit
idto create; passidto update. status: "published"makes it appear (defaultdraft). Draft records do NOT render — the most common "my records don't show up" cause.
4. Render on the storefront
Records are exposed to Liquid via the lazy content Drop and routed by url_pattern:
{% for house in content.houses %}
<a href="{{ house.url }}">{{ house.title }} — {{ house.price | money }}</a>
{% endfor %}
Build the list + detail sections with shop-build-section; wire the full site (commerce off, router, templates) with shop-content-site.
Create the new route's theme files — section-based .json templates
A content type introduces a NEW route and NEW pages; add theme files for them as .json section templates (customizer-editable), not hard-coded .liquid:
templates/<code>-list.json— the list page (at theurl_patternprefix, e.g./properties), referencing a list/grid section.templates/<code>.json— the detail page (/properties/:slug), referencing a detail section rendering onerecordincl. image/gallery.- The router auto-resolves these from
url_pattern(templates/<type.template>detail /<type.template>-listlist;templatedefaults tocode). The customizer auto-surfaces both pages only if these.jsontemplates exist — skip them and the route 404s with nothing to edit. - Every section carries a complete
{% schema %}(core §F); bind to record fields, no hard-coded content. - DESIGN them, don't just wire them — these are first-class design surfaces (core §K), not a bare dump. The list page is a styled, filterable grid of real cards; the detail page lays out the record's fields richly (gallery/image, specs, description, CTA) — decompose into sections like a homepage, not one monolithic
{% for %}. A list/detail that renders an unstyled stack looks half-finished. After building, screenshot-verify both pages desktop+mobile against the design (core §D) — they are pages like any other.
Filter, search, sort & pagination on the list page — server-side, NO client JS
The list page handles filtering/search/sort/pagination from URL query params server-side. Build the <code>-list section to use these; do NOT hand-roll client JS over a giant {% for %}. Rendering the controls is MANDATORY: a list section that loops records without the GET form (q input + sort select) and the pagination nav is REJECTED by the write linter (section-list-controls).
URL grammar (all optional, combine freely):
?q=<text>— search title + text/richtext/url fields.?<fieldCode>=<value>— facet filter; repeat or comma-join (?style=Modern&style=Minimal). Exact, case-insensitive; multiselect/gallery match if ANY selected value is present. AND across fields, OR within a field.?sort=<fieldCode>/-<fieldCode>(desc) /title/newest/oldest. Number/integer sort numerically.?per_page=<n>(default 12, max 60) ·?page=<n>.
Liquid context on the list page:
records— current page, filtered/sorted. Loop it.pagination—{ current_page, total_pages, total, per_page, has_prev, has_next, prev_url, next_url, pages[]{num,url,is_current} }. URLs preserve active q/filters/sort.search(currentq),sort,filters(map fieldCode→[values] —{% if filters.style contains 'Modern' %}).total(filtered) ·total_all(unfiltered). Facet options come fromcontent_type.fields[].options.
Pattern — a real, JS-free faceted list (in the <code>-list section):
<form method="get">
<input name="q" value="{{ search }}" placeholder="Search…">
{% for f in content_type.fields %}{% if f.type == 'dropdown' or f.type == 'multiselect' %}
<fieldset><legend>{{ f.label }}</legend>
{% for o in f.options %}
<label><input type="checkbox" name="{{ f.code }}" value="{{ o }}"
{% if filters[f.code] contains o %}checked{% endif %}> {{ o }}</label>
{% endfor %}
</fieldset>
{% endif %}{% endfor %}
<select name="sort" onchange="this.form.submit()">
<option value="">Featured</option>
<option value="title" {% if sort == 'title' %}selected{% endif %}>A–Z</option>
<option value="-price" {% if sort == '-price' %}selected{% endif %}>Price ↓</option>
</select>
<button type="submit">Apply</button>
</form>
{% for rec in records %}{% render 'card', record: rec %}{% endfor %}
{% if pagination.total_pages > 1 %}
<nav class="pager">
{% if pagination.has_prev %}<a href="{{ pagination.prev_url }}">‹</a>{% endif %}
{% for p in pagination.pages %}
<a href="{{ p.url }}"{% if p.is_current %} aria-current="page"{% endif %}>{{ p.num }}</a>
{% endfor %}
{% if pagination.has_next %}<a href="{{ pagination.next_url }}">›</a>{% endif %}
</nav>
{% endif %}
The GET reloads; the server filters/sorts/paginates; checkboxes stay checked via filters. (You MAY layer JS on top, but the baseline must work without it.)
Verify with list_content_records — same shape, same engine: list_content_records { type_code, search, filters: { category: ["Fashion"] }, sort: "-price", page, per_page } → { data, total, total_all, page, per_page }.
Image/gallery fields: the admin offers a media-library picker (folder content); via the MCP pass URLs in values (re-host externals first — §3 / core §C).
Don't reinvent built-ins — menu, pages, blog already exist
A content type is ONLY for custom dynamic entities. Reuse, do NOT model as content types:
- Navigation → the header/footer section's nav link blocks ('link' + nested 'dropdown'/'column'; there is no menu tool — core §H).
- Static pages (About, Contact, Privacy) → pages (
create_page/publish_page). - Blog / articles / news → the blog (
create_blog_post) — unless the design needs fields the blog can't hold. A content type earns its place only when the entity has structured typed fields the built-ins lack (price, beds, status, gallery…).
Forms (newsletter / contact / lead capture) — a content type with is_form: true
A storefront form is a content type flagged is_form: true — each submission becomes a record (a private inbox entry). The create_content_type MCP tool does NOT expose the form fields (is_form, submit_status, notify_email), so create it via the graphql_mutation escape hatch:
⛔
is_form: trueis MANDATORY for ANY form — contact, newsletter, lead, booking. It is the SINGLE switch that makes the type a private inbox: form types get NO public storefront route (the/<code>/:sluglist/detail 404s) and their records are never publicly listed. Forget it (is_form: false, the default) and you've created a PUBLIC content listing at/<code>whose records (names, emails, phones) can be exposed — the exact "form data leaked at /lien-he" bug. If a type RECEIVES submissions, it MUST beis_form: true. Also setsubmit_status: "submitted"(NEVER"published") — published would make a submission eligible for public listing.
mutation { createContentType(input: {
code: "newsletter", label: "Newsletter Signups", singular: "signup",
is_form: true, # ← MANDATORY for a form. Without it = public listing leak.
submit_status: "submitted", # ← NEVER "published" (submissions are private inbox entries)
notify_email: "owner@store.com", # optional — emails the owner on each submit
fields: [
{ code: "email", label: "Email", type: "text", required: true, position: 1 },
{ code: "name", label: "Name", type: "text", required: false, position: 2 }
]
}) { id code } }
A form type needs no url_pattern — it has no public page. It also needs no list/detail template (never browsed publicly). Submissions live in admin (Content ▸ Forms) + list_content_records { type_code }.
Render the form to submit via AJAX (fetch, no page reload) to the built-in endpoint /api/storefront/forms/submit. The Next.js route resolves the store from the proxy header, runs honeypot + rate-limit + optional reCAPTCHA, forwards to submitContentRecord, and returns JSON { ok, status } when called with header X-Requested-With: fetch (or 303-redirects for a no-JS <form> fallback). Prefer the built-in web-form section (form_type_code setting) — it already does AJAX + inline success/error + honeypot. If hand-building, required parts:
- hidden
type_codebound to a form-picker setting —value="{{ section.settings.form_type_code }}"+ schema setting{"type":"text","id":"form_type_code","default":"<the type's code>"}(the editor renders it as the form picker; a LITERAL value is a lint reject — §F) · hiddenredirect_to={{ request.path }}(no-JS fallback) - a honeypot
<input name="_hp">hidden off-screen (bots fill it → silently dropped) - one
<input name="<field_code>">per field (e.g.name="email"required) - a JS submit handler:
preventDefault(),fetch(... { headers:{'X-Requested-With':'fetch'}, body:new FormData(form) }), read JSONstatus, show an inline message, reset on success — never let the page navigate/reload.
<div id="nl-flash" hidden></div>
<form id="nl-form" method="post" action="/api/storefront/forms/submit"
data-msg-success="{{ section.settings.success_text | default: 'Thanks!' | escape }}"
data-msg-error="Something went wrong. Please try again." data-msg-invalid="Please enter a valid email.">
<input type="hidden" name="type_code" value="{{ section.settings.form_type_code | default: 'newsletter' }}">
<input type="hidden" name="redirect_to" value="{{ request.path }}#newsletter">
<input type="text" name="_hp" tabindex="-1" aria-hidden="true" style="position:absolute;left:-9999px">
<input type="email" name="email" required placeholder="Enter your email">
<button type="submit">Subscribe</button>
</form>
<script>(function(){var f=document.getElementById('nl-form'),fl=document.getElementById('nl-flash');
f.addEventListener('submit',function(e){e.preventDefault();var b=f.querySelector('button');if(b)b.disabled=true;
fetch(f.action,{method:'POST',headers:{'X-Requested-With':'fetch','Accept':'application/json'},body:new FormData(f)})
.then(function(r){return r.json();}).then(function(j){var s=(j&&j.status)||'error';fl.hidden=false;fl.textContent=f.getAttribute('data-msg-'+s)||f.getAttribute('data-msg-error');if(s==='success')f.reset();})
.catch(function(){fl.hidden=false;fl.textContent=f.getAttribute('data-msg-error');})
.finally(function(){if(b)b.disabled=false;});});})();</script>
A site-wide form (newsletter) belongs in the global footer section; a page-specific one (contact) goes on that page's template. Verify by submitting a test (expect the inline success message + no reload), confirm the record appears in admin, then delete the test record. Also confirm the form type's route 404s publicly (e.g. /newsletter must NOT render a listing).
5. Verify — completeness checklist
- Type(s) created with every field the design needs (modeled from the design — §0).
- Real published records (draft = invisible), each with re-hosted images (core §C; no blank cards, no hotlinks).
-
templates/<code>-list.jsonrenders the grid;templates/<code>.jsonrenders one record incl. image/gallery — opening a record URL must NOT 404/empty. - Both pages + every section customizer-editable (core §F); they appear in the customizer dropdown.
- Navigation (built-in menu), static pages, blog if the design has one.
- If content-only:
set_commerce_enabled false(→shop-content-site). - Verify live (core §D/§E):
list_content_recordshas data; the list URL AND a record URL render with images; every link 200 (check body);clear_storefront_cacheif stale.
Notes
- IDs/numbers in
valuescan be strings (stored via EAV).
