[email protected]
Alhambra, California·Serving businesses worldwide
Home/Docs
Documentation

iHomepage CMS Documentation

Official iHomepage CMS documentation. An open foundation for website content management — a free-form content model, native multilingual support, SEO-friendly by default, template-driven rendering and AI agent integration. Covers getting started, core concepts, design principles, templates, routing, static publishing, admin and API, configuration and a launch checklist.

iH
iHomepage CMS Team
Updated 2026-09-28 · 21 sections · 53 min read

iHomepage CMS is an open foundation for website content management. It provides only the most basic, stable mechanisms — one content table, two kinds of relationships, one set of template rules — and leaves everything else for you to define, which gives it more freedom than comparable CMS products. Multilingual support, SEO-friendliness and templating are part of its nature rather than plugins, and its consistent, predictable APIs let AI agents connect directly to build and maintain sites for you.

This documentation is written for two audiences: the teams who build and run websites, and the AI agents that do website work on their behalf. After reading it you will know how to set up a site from scratch, how to organize content, how to write templates, which capabilities come out of the box, and the rules for working with this CMS.

Introduction

What iHomepage CMS is

iHomepage CMS is a classic website content management system — and an open foundation:

  • What the foundation provides: content storage, routing, template rendering, static publishing, multilingual support, SEO infrastructure, user accounts, forms, comments, payments and an admin console. Maintained and upgraded continuously.
  • What your site decides: content structure, templates, page design, configuration, and any functionality unique to your site.

You don't deploy servers, design databases or build an admin panel. You define content, write templates and publish content.

Key features

FeatureDetails
High freedomYou define content types, the relationships between them and how every page looks; a new kind of content needs no schema change and no plugin
Native multilingualLanguage prefixes, translation groups, hreflang and language switching are built in and enabled with a single setting
SEO-friendlyCanonical URLs, canonical tags, path-based pagination, table-of-contents anchors and static publishing — search-friendly by default
Template-drivenEvery page is rendered by a Twig template, kept as a file or edited online in the admin console
AI & agent readyA built-in MCP server: agents sign in with OAuth and manage the site in plain language; an inferable content model with templates and content as plain text
Batteries includedUsers, forms, comments, likes and favorites, email subscriptions, online payments and an admin console are built in

How it differs from traditional CMSs

Compared with a traditional CMS such as WordPress:

Traditional CMS (e.g. WordPress)iHomepage CMS
New content typesRegister a custom post type, usually with code or a pluginA new taxonomy value plus a template with the same name
Relationships between contentBeyond categories and tags, usually plugins or custom fieldsBuilt-in many-to-many associations whose meaning comes from the types at both ends
Custom fieldsOften a fields pluginmeta_json, read directly in templates
MultilingualRequires a multilingual pluginNative
SEOOften completed with an SEO pluginBuilt in by default
PresentationCustomized within a theme frameworkTemplates are entirely yours, with no theme framework to work around
PerformanceOften a caching pluginBuilt-in static publishing serves pages as static files
AI integrationDepends on pluginsBuilt-in MCP server with OAuth sign-in, and documentation written for agents too

What it is good for

  • Corporate websites, brand sites, B2B export sites
  • Blogs, news sites, knowledge bases, documentation sites
  • Product catalogs, case libraries, download centers
  • Multilingual websites
  • Light e-commerce with memberships, lead forms and online payments

How this documentation is organized

PartContents
Getting StartedBuild a working site from zero
Core Concepts / Design PrinciplesWhy the CMS works the way it does
Content Model / Templates / RoutingThe three areas you use every day
Multilingual / SEO / Static Publishing / Users & Payments / AI & AgentsBuilt-in features
Admin / API / Configuration ReferenceReference material
Security / Responsibilities & Rules / ExtendingRules for working with this CMS
Launch Checklist / FAQ / GlossaryWrapping up and troubleshooting

Getting Started

Step 1: Choose your site type

Not every website needs the CMS. Decide which kind of site you are building:

TypeGood forCMS content database
Static siteA few fixed pages, rarely updatedNot needed
Static pages + standalone appOnline tools, calculators, SaaS front endsNot needed
CMS content siteArticles, products, cases, docs — content that keeps growingNeeded
Hybrid siteYour own home page and tools; blog, products and docs run on the CMSNeeded

Hybrid sites are the most common: pages and apps you build yourself always take priority, and the CMS serves the content pages.

Step 2: Know your site directory

Every website has its own site directory:

your-site/
├── public/          Public files: static pages, CSS, JS, images, fonts, front-end build output
├── templates/       (optional) Twig file templates
└── db/
    └── cms.db       (optional) The site's CMS database: content, templates, settings, users, forms, orders
  • Files in public/ are served directly; each URL maps to a file path.
  • Templates can live in the templates/ directory or in the database (edited in the admin console). Pick one.
  • Create the content database from the platform's standard schema. Never use an empty file or a copy of another site's database.
  • Only cms.db inside db/ is treated as the content database. Give your own business databases any other name and the CMS will never read them.

Step 3: Basic settings

Fill these in under Site Settings in the admin console:

SettingExampleDescription
site_nameAcmeSite name, appended to page titles
domainwww.acme.comCanonical domain
schemehttpsProtocol
home_title / home_descriptionHome page title and description

See Configuration Reference for every setting.

Step 4: Write your first templates

A header partial, header.html:

<!doctype html>
<html lang="{{ SITE.html_lang|default('en') }}">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>{{ base.title }}</title>
  <meta name="description" content="{{ base.description|e }}">
  <link rel="canonical" href="{{ base.url }}">
  <link rel="stylesheet" href="/css/site.css">
</head>
<body>

The home page template, home.html:

{{ include('header.html') }}
<main>
  <h1>{{ SITE.site_name }}</h1>
  {% set latest = list_post({taxonomy: 'article'}, 6) %}
  {% for item in latest.list %}
    <a href="{{ item.url }}">{{ item.title }}</a>
  {% endfor %}
</main>
{{ include('footer.html') }}

The article template, article.html:

{{ include('header.html') }}
<article>
  <h1>{{ the.title }}</h1>
  <div class="content">{{ the.body|raw }}</div>
</article>
{{ include('footer.html') }}

Step 5: Publish your first content

Create a content item in the admin console:

FieldValue
TitleHello World
Type (taxonomy)article
URL/articles/hello-world/
StatusPublished

Open https://your-domain/articles/hello-world/ and the CMS renders it with article.html. It also appears in the home page list.

Step 6: Add the admin console and user panel

Add a class to your login link:

<a href="#" class="user-panel-login-title">Login</a>

The CMS adds /init.js to every page automatically. When it finds user panel elements on the page it loads the panel, so you don't need to add a script tag yourself. /init.js also handles visit statistics, which appear on the admin dashboard. Both can be adjusted with settings:

SettingValues
cms_js_panelauto (default, load the panel only when the page has panel elements), on (load on every page), off (never load)
cms_js_statsOn by default; set to 0 to turn visit statistics off

Visitors get a sign-in dialog; site administrators can switch to the content admin console after signing in. The first administrator of each site is set up by the platform.

That's it — you have a content site you can keep running. Read Core Concepts and Content Model next.

Core Concepts

How a request is handled

Visitor requests https://your-domain/some-path
  ↓
Does the site's public/ contain this file?
  ├─ Yes: return it directly (static file, fastest)
  ↓ No
Hand over to the CMS
  → Platform API (e.g. /user/api/login)? Handled by that API
  → Does the site have a content database? No: return 404
  → Is there a redirect for this path? Redirect to its destination
  → Look up content or a template for the path, render and return the page

Three things to remember:

  1. Your files always win. A file in public/ is always served first; the CMS never overrides it.
  2. The CMS is a content fallback, not an application container. Pages and apps that already run on their own don't need to move into the CMS.
  3. A 404 on a site without a content database is normal. Don't create an empty database just to get rid of it.

Everything is content

In iHomepage CMS, articles, products, categories, tags, cases, downloads, stores, revisions… are all "content", stored in a single content table, post, and told apart by taxonomy (type).

Two kinds of relationships

RelationshipCardinalityStored inExample
HierarchyOne-to-oneparent_idAn article belongs to a category; a sub-category belongs to a parent
AssociationMany-to-manyRelation table post_postAn article has many tags; a product is sold in many stores

Templates decide the presentation

The CMS does exactly the same thing for every content item: it prepares the item itself, its parent, its associations and the list of items under it, and hands them to the template. **Whether an item renders as a detail page or a list page is decided by the template — the CMS never decides.**

The URL is the entry point

Every content item has one canonical URL (url). Visitors, search engines, static publishing and internal links all revolve around it.

Design Principles

Understand these and you can work out the answer to most questions on your own.

One table for all content

Adding a new content type or a new kind of relationship never requires a database schema change: a new type is just a new taxonomy value, and a new relationship is just an association between two types. The structure stays stable, and every upgrade to the foundation benefits your site directly.

Mechanism belongs to the platform, meaning belongs to the site

The platform provides mechanisms only: one content table, two relationship types, one template lookup rule, one rendering path. "What counts as a category", "what this association means" and "what the page looks like" are all defined by the site. The platform does not — and should not — know your business.

Your site comes first

Your site's own files, pages and apps always take priority. The CMS only handles paths your site doesn't cover.

Generality

The foundation only takes in general-purpose capabilities. Every capability added to it must be general, and by default must not change the behavior of any existing site. The platform has no per-site switches, exceptions or special cases.

Backward compatibility

Upgrades don't change the output of existing pages by default. When an old way of doing something is replaced, the old way keeps working and is marked "deprecated"; sites are not forced to change right away.

Secure by default

Rich text is sanitized on save, credentials are never echoed back, anonymous uploads are private by default, and admin permissions are checked on every request. Security lives in the platform, so sites don't have to implement it individually.

Content Model

taxonomy: content types

taxonomy values are defined freely by each site; the platform presets none. Common conventions:

article / post   articles     news      news          blog      blog posts
product          products     service   services      project   projects
case             cases        document  documents     download  downloads
dir              categories   tag       tags          version   revisions

A new content type = a new taxonomy name + a template with the same name. Nothing else.

parent_id: hierarchy

parent_id points to the item one level up and expresses one-to-one membership only:

  • Category → parent category: multi-level menus
  • Article → its category: breadcrumbs
  • Revision → original item: version history

Use parent_of(the) in templates to get the parent.

Associations: many-to-many

post_post(post_id, child_post_id, weight)
  • Direction: post_id is the owner, child_post_id is the member. For example "category → article", "article → tag". Write it the wrong way round and the same data means the opposite.
  • The meaning of a relationship comes from the types at both ends; no relationship type is stored:
(dir, article)       article is in this category     (article, tag)       the article's tags
(product, service)   optional add-on service         (store, product)     products sold in this store
(guide, download)    downloads for this guide        (course, teacher)    the course's teachers
  • weight is the item's position within this group. The same article can sit at different positions in different categories.

In templates, related(the, 'tag') returns "what this item is associated with", and list_related(id, …) returns "what is listed under this item".

One-to-one uses parent_id; many-to-many uses associations. Never store the same relationship in both.

Composition examples

  • Corporate site: dir categories + article items (in categories) + case items (associated with industry)
  • E-commerce: product under dir, tagged with tag, associated with optional service, sold in several stores
  • Documentation site: guide in categories, associated with download attachments, version for history
  • Community: thread belongs to topic, associated with tag

Standard fields

FieldDescription
titleTitle
descriptionSummary, also used as the page description
keywordsKeywords
bodyBody HTML
urlCanonical URL — always set it explicitly
taxonomyContent type
templateTemplate override; when empty the template named after taxonomy is used
status2 means published; anything else is not visible
parent_idParent item
pic_urlCover image
authorAuthor
publish_time / update_timePublish and update time
pricePrice, in cents (enter 5999 for $59.99). The cart, checkout and orders all use cents
weightGlobal sort weight
is_top / is_headline / is_featured / is_homePinned, headline, featured and home-page flags
click_count / like_count / fav_countView, like and favorite counters
lang / slugMultilingual: language code and translation group key
meta_jsonCustom fields (JSON)

meta_json: custom fields

Anything the standard fields don't cover — product specs, FAQ lists, price ranges, external links, button labels — goes into meta_json:

{"specs": {"weight": "1.2kg", "size": "30×20cm"}, "faq": [{"q": "Is shipping free?", "a": "Free over $99"}]}

Read it directly in templates, no parsing needed:

{{ the.meta_json.specs.weight }}
{% for item in the.meta_json.faq %}<dt>{{ item.q }}</dt><dd>{{ item.a }}</dd>{% endfor %}

Every field whose name ends in _json is decoded automatically. Empty values decode to an empty array, and invalid JSON is kept as the raw string, so pages never break because of it.

Gallery: the "Gallery" images in the admin content editor (often used as a carousel on the content page) are stored in meta_json.gallery as an array of image URLs; other keys are left untouched:

{% for src in the.meta_json.gallery %}<img src="{{ src }}" alt="">{% endfor %}

Don't change the database schema for a new field. If a field is something every site would need, raise it as described in Extending & Feature Requests.

Content status

statusMeaning
2Published: accessible, listed, statically published
0 and other valuesDraft or offline: 404 on the front end; existing static pages are removed automatically
-1Deleted (recoverable; associations are kept)

Templates

Templates use Twig syntax.

Where templates come from

Looked up in this order; the first match wins:

  1. {name} in the site's templates/ directory
  2. {name}.html in the site's templates/ directory
  3. A database template named {name} or {name}.html

Don't keep the same template in both places: a file template hides the database template with the same name, so edits in the admin console won't take effect. Database templates must use the twig engine.

Which template renders a page

PageTemplate
Home /home
Search /searchsearch
Content pageThe item's template field → otherwise the template named after taxonomy → otherwise post
Not found404 (optional; plain-text 404 otherwise)

Layouts and partials

Option 1: include partials. Each page template includes its own header and footer:

{{ include('header.html') }}
…page content…
{{ include('footer.html') }}

Option 2: base_layout. Create base_layout.html and mark where page templates go with the literal placeholder {{BODY}}:

<!doctype html>
<html lang="{{ SITE.html_lang }}">
<head><title>{{ base.title }}</title></head>
<body>
  {{ include('header.html') }}
  {{BODY}}
  {{ include('footer.html') }}
</body>
</html>

When base_layout.html exists, page templates are wrapped in it automatically — unless the page template is already a complete HTML document.

Template variables

VariableContents
SITESite settings: site_name, domain, scheme, logo, menu, URL_ENDING, home_title, home_description; multilingual sites also get lang, lang_prefix, html_lang, languages
baseThe current page's title, description, keywords and url (full canonical URL, including the page number on paginated pages)
theThe current item, with *_json fields decoded
the.termParent item
the.taglistAll associated items
the._toc / the._toc_htmlTable of contents built from the body's level-2 headings (array / ready-made HTML); anchors are inserted into the body
the.commentsApproved comments
LISTItems listed under the current item, paginated: {list, total}
TERMSFilters parsed from query parameters
GETThe current request's query parameters
PAGECurrent page number; path-style pagination (/blog/page/2/) is not in the query string, so this is the only source

Template functions

FunctionPurpose
include(name, vars)Include a partial; the second argument passes extra variables to it
list_post(where, limit, page, order)Query content by conditions; returns {list, total}
list_related(ids, limit, page, order, where)Items listed under the given items; multiple ids are intersected, grouped form below
related(item, taxonomy)Items this item is associated with, optionally filtered by type
parent_of(item)Parent item
translations(item)This item in every language
make_paginate(total, page, size)Pagination HTML; omit page to use the current page
get_options(name)Read one site setting

Examples:

{# Category page: list its articles with pagination #}
{% for item in LIST.list %}
  <a href="{{ item.url }}">{{ item.title }}</a>
{% endfor %}
{{ make_paginate(LIST.total) }}

{# Home page: 6 featured cases sorted by weight #}
{% set cases = list_post({taxonomy: 'case', is_featured: 1}, 6, 1, 'weight') %}

{# Detail page: tags and breadcrumb #}
{% for tag in related(the, 'tag') %}<a href="{{ tag.url }}">{{ tag.title }}</a>{% endfor %}
{% set parent = parent_of(the) %}
{% if parent %}<a href="{{ parent.url }}">{{ parent.title }}</a>{% endif %}

{# Multi-filter: items in both category 3 and tag 7 #}
{% set items = list_related([3, 7], 12, 1) %}

{# Grouped filter: Chinese items under area 101 or 102, and under district 205 #}
{% set items = list_related([[101, 102], [205]], 12, 1, [], {taxonomy: 'firm', lang: 'cn'}) %}

ids in list_related comes in two forms: a flat [3, 7] means listed under every one of them; a grouped [[101, 102], [205]] means OR within a group, AND between groups. The two forms can be mixed, and empty groups are ignored. The fifth argument where adds conditions, written the same way as for list_post.

Sorting

The fourth argument of list_post and list_related controls sorting and accepts four forms:

{{ list_related(8, 10, 1, 'weight') }}                               {# one field, descending by default #}
{{ list_related(8, 10, 1, ['weight', 'publish_time']) }}             {# several fields #}
{{ list_post({taxonomy: 'product'}, 12, 1, {'price': 'ASC'}) }}      {# explicit direction #}
{{ list_related(8, 10, 1, [{'weight': 'DESC'}, {'title': 'ASC'}]) }} {# ordered list #}
  • Defaults: list_post sorts by publish time descending; list_related by creation order descending. Specify the order explicitly whenever it matters.
  • Sort entries with a misspelled field or direction are ignored and the default order is used; the page won't error, so check the result.

Template rules

  1. Never hard-code content ids. Locate content by taxonomy, slug or url. A stale id doesn't raise an error — the list just silently comes back empty.
  2. Escape correctly. Templates don't auto-escape. Use {{ the.body|raw }} for editor-saved body HTML; never add |raw to untrusted data such as visitor input or query parameters, and use |e where needed.
  3. Use root-relative asset paths: /css/site.css, not css/site.css, or assets will 404 on nested URLs.
  4. Use base for SEO tags instead of hard-coding titles and descriptions in the header.
  5. CSS, JS and robots.txt can be stored as templates: templates whose name starts with / (e.g. /app.css, /robots.txt) are served as-is and statically published. You don't need to write a sitemap — the CMS generates one (see "SEO"). In the templates/ directory, only css, js, json, xml, txt and ico files are directly addressable, so layout partials are never exposed on the web.
  6. Read the site name, logo, navigation and contact details from settings: use SITE.site_name, SITE.logo, SITE.menu and get_options('contact_info') instead of hard-coding them. Changes made in the admin console or by an agent then apply across the whole site at once.

Routing & URLs

Routing rules

RequestResult
A path with an enabled rule under Redirects in the admin consoleRedirect to the destination (takes priority over every rule below, except platform APIs)
/Home page, home template
/index.html301 to /
/search?keyword=termSearches titles and summaries of published content, search template
/uploads/…Attachments
/.well-known/…Domain verification and protocol discovery files, managed under .well-known in the admin console
/user/api/…, /user/pay/…, /cms/api/…, /mcpPlatform APIs
/init.jsPage entry script, added to every page by the CMS
Same path as a template whose name starts with /That template's output
Path ending in neither / nor .html301 adding the trailing /
Same path as an item's urlThat item, rendered
Non-canonical paths such as /{taxonomy}/{id}301 to the item's canonical URL
/{list-url}/page/{n}/Page n of the list
Anything else404

/mcp, /.well-known/, /init.js, /js/analysis2.js and the platform API paths above are reserved by the platform. Don't use them for content or templates.

URL rules

  • One canonical URL per item, stored in url; every other path that reaches it redirects there with a 301.
  • Use one style site-wide: directory style /blog/my-post/ or file style /blog/my-post.html. The URL_ENDING setting determines the ending of URLs generated for items without an explicit url.
  • List page URLs end with / (e.g. /blog/) so they can use path-based pagination, which can be statically published.
  • Don't put .html in a directory segment (e.g. /design/index.html/26.html).
  • Don't distinguish content with query parameters. URLs with query strings are never statically published.

Redirects

When content is reorganized or a URL changes, don't let the old URL return 404 — send it to the new one under Redirects in the admin console.

FieldMeaning
SourceA site path such as /old/page.html; matched exactly by path, without domain or query string. One rule per path
DestinationA site path (/new/page/) or a full http(s):// URL
Code301 (default), 302, 307 or 308
StatusEnabled / disabled; disabled rules have no effect
NoteFree text, e.g. why the page moved

The console also shows each rule's hit count and last hit time, so you can tell whether an old URL still gets traffic.

  • Highest priority: while a rule is enabled, the path always redirects, even if it still matches a published item or a template. The home page / and platform API paths cannot be redirected.
  • The query string is carried over: /old?utm_source=x goes to /new?utm_source=x; it is not appended when the destination already has one.
  • Static files: enabling a rule deletes the static file the CMS generated at that path, and the path is no longer generated. A file you placed in public/ yourself is never deleted; the console reports it as a conflict, and the redirect won't take effect until you remove it.
  • Avoid chains and loops: the console warns when the destination is itself redirected — point the rule at the final URL instead.
  • 301/308 are permanent and remembered by browsers and search engines for a long time; use 302/307 for temporary moves.
  • Redirects are for URLs that are no longer used. Non-canonical paths of an item (e.g. /{taxonomy}/{id}) already 301 to its canonical URL automatically; no rule is needed.

Pagination

  • Path-based is recommended: /blog/page/2/. Query-based ?page=2 works too.
  • /blog/page/1/ redirects to /blog/ with a 301; out-of-range pages return 404 instead of empty list pages.
  • Items per page come from the page_size setting (default 15). make_paginate matches it automatically when you omit the third argument.
  • The second argument (current page) is optional too. GET.page|default(1) always evaluates to page 1 on path-style pagination URLs.
  • With filter parameters (e.g. ?dir=3&tag=7), pagination links automatically switch to ?page=N.

Multilingual

Multilingual support is optional. It is enabled only on sites that configure languages; single-language sites are unaffected.

Configuration

{"default": "en", "available": {"en": "English", "cn": "中文", "es": "Español"}, "hreflang": {"cn": "zh-CN"}}

The short form ["en", "cn", "es"] also works; the first entry is the default language. Language codes may contain only lowercase letters, digits and -.

The URI convention

The same content has exactly the same URI in every language, differing only by a language prefix. The default language has no prefix.

/guides/getting-started/        English (default)
/cn/guides/getting-started/     Chinese
/es/guides/getting-started/     Spanish

This is what makes multilingual work: language switching and hreflang are derived directly from this rule, without any lookups.

  • Create every language version of a page together.
  • If a language really lacks a page, a request to its prefixed URL is redirected (302) to that language's home page instead of returning 404.

Content fields

  • lang: the item's language code
  • slug: translation group key. Items with the same type and slug but different lang are translations of each other

When an item is rendered, the page language comes from the item's own lang, not from the URL prefix. <html lang>, hreflang, the language switcher and SITE.lang_prefix are all derived from it, so a wrong lang breaks all of them at once.

Field rules

On a multilingual site, every item that has a url and renders as a page must satisfy:

  1. lang is required and must be one of the codes in available. Write the default language explicitly too (e.g. en); do not leave it empty. An empty lang still renders in the default language, but lists filtered by language, such as list_post({lang: SITE.lang}), will leave it out.
  2. lang matches the URL prefix. URLs of non-default languages start with /{lang}/; URLs of the default language have no prefix. If lang is empty or the default language while the URL sits under /cn/, the page renders as the default language and the switcher and hreflang produce broken URLs like /cn/cn/....
  3. **Translations of one item share the same taxonomy, the same slug, and the same URL once the language prefix is removed.** A slug is unique within one taxonomy and one lang.

Items without a url that only serve as filters or data are exempt from rule 2, but should still carry a lang so they can be selected by language.

On single-language sites (no languages configured), leave lang and slug empty.

When you later turn on multilingual support for a single-language site, first set lang on existing content to the default language code, then publish the other languages.

In templates

<html lang="{{ SITE.html_lang }}">

{# hreflang #}
{% for t in translations(the) %}
  <link rel="alternate" hreflang="{{ t.hreflang }}" href="{{ t.url }}">
{% endfor %}

{# Language switcher #}
{% for l in SITE.languages %}
  {% if l.current %}<strong>{{ l.label }}</strong>{% else %}<a href="{{ l.prefix }}/">{{ l.label }}</a>{% endif %}
{% endfor %}

{# List content in the current language #}
{{ list_post({taxonomy: 'guide', lang: SITE.lang}, 10) }}

{# Internal links keep the language prefix #}
<a href="{{ SITE.lang_prefix }}/about/">About</a>

On single-language sites SITE.lang and SITE.lang_prefix are empty and translations() returns an empty array, so one template works for both single- and multi-language sites.

SEO

iHomepage CMS treats search-engine friendliness as default behavior, not a plugin you install.

Built in

CapabilityDetails
Title, description, keywordsEvery page gets base.title / base.description / base.keywords; content page titles are "Title | Site name"
Canonicalbase.url is the full canonical URL; paginated pages include the page number so page 2 onwards isn't treated as a duplicate
One URL per pageNon-canonical URLs 301 to the canonical one; missing trailing / is added with a 301; /index.html 301s to /
Path-based pagination/blog/page/2/ is crawlable and statically published
No empty pagesOut-of-range pages return 404, so there are no endless empty list pages
Multilingualhreflang, <html lang>, the language-prefix convention
Table of contentsLevel-2 headings in the body produce a table of contents with anchors (the._toc_html)
Static publishingPages are pre-generated as static files for fast responses
Sitemap/sitemap.xml is generated automatically and refreshed daily; see below
404 logMissing URLs are logged and visible in the admin console
AttachmentsImages and other attachments get stable /uploads/ URLs

What you need to do

  • Use base.title, base.description and base.url in the header — don't hard-code them.
  • Give every item a title and description, and an explicit, short, stable url.
  • Structure bodies with level-2 and level-3 headings; one <h1> per page (usually the title, output by the template).
  • Add alt text to images.
  • Provide robots through a template named /robots.txt, and include Sitemap: https://your-domain/sitemap.xml in it.
  • Output hreflang on multilingual sites.
  • Don't change published URLs casually; if you must, make sure the old URL redirects.

Sitemap

The CMS generates sitemap files from your content — no plugin, no manual upkeep:

  • /sitemap.xml is an index pointing to /sitemap_1.xml, /sitemap_2.xml, … with at most 50,000 URLs per file.
  • It lists the home page and every published item (categories, tags and all other content types included), each with a <lastmod> taken from the item's update time.
  • Not listed: URLs with a redirect rule, off-site URLs, and pagination pages.
  • It is regenerated automatically once a day, whether or not static publishing is on. To reflect content changes right away, click "Rebuild sitemap" on the admin console's Tools page.
  • The sitemap is a pre-generated file, not computed on request, so crawlers fetching it never slow down even a very large site. A new site returns 404 for /sitemap.xml until the first generation has run.
  • Your site must not write /sitemap.xml or /sitemap_{number}.xml: these file names belong to the CMS. Files with these names written by site scripts, templates or uploads are overwritten by the CMS, and templates with these names have no effect.
  • If pages outside your content (for example aggregate pages your site generates itself) need a sitemap, generate it under a different file name (such as /sitemap_brands.xml) and submit it yourself (add a Sitemap: line to robots.txt, or submit it in the search engine's webmaster tools). The CMS does not manage these files and does not link them from /sitemap.xml.

Static Publishing & Performance

How it works

The CMS pre-generates pages as static files in the site's public/, so the web server returns them directly without touching the database or rendering templates.

  • Updated on save: saving an item regenerates its old and new URLs (including pagination), the home page, its parent, and the owners associated with it.
  • Full rebuild: regenerates every page the content model can derive.
  • Visitor requests fill the gaps: when a published URL has no static file yet, the first visit renders it dynamically and saves the file at the same time; from then on the web server returns it directly.
  • All three produce identical files: they go through the same publish method and the same rendering.

Enabling it

  • Set static_publish to yes (off when unset).
  • Nothing is generated while the site is blocked (site_blocked is yes).

What gets generated

  • The home page
  • Every published item's URL, plus its path-based pagination
  • Resource templates whose name starts with / (CSS, JS, robots.txt, …)
  • Publicly accessible attachments

Not included: URLs with query strings (search, multi-filter combinations). They are always rendered dynamically.

Things to know

  • After changing templates or site settings, only the home page updates automatically; other pages need a full rebuild.
  • The CMS only manages files it generated. Files you placed yourself with the same name, or generated files you edited by hand, are skipped and never overwritten. Conversely, don't edit generated static pages by hand — once edited, they stop being updated.
  • Redirected paths are never generated; a generated file is deleted when its redirect is enabled (see Redirects).

Users, Forms & Payments

These capabilities are provided by the platform. Call them directly — don't build your own.

Users

  • Sign up, sign in (username / email / mobile), Google sign-in, sign out
  • Profile, change password, forgot password (email code)
  • Change mobile number or email (verification code)
  • Sessions use a first-party cookie and the APIs live on your own domain, so there is no CORS setup

Engagement

  • Comments: submitted by signed-in users, shown in the.comments after approval
  • Likes and favorites: idempotent, counters maintained automatically
  • Email subscriptions: de-duplicated by email

Forms

/user/api/form_submit accepts forms with any fields (contact, inquiry, registration…):

  • Submissions are stored under Forms in the admin console
  • The site owner (setting contact_info.email) gets a notification email
  • Optionally the submitter gets a receipt (setting form_receipt). Receipts are sent through this site's sendmail_smtp when configured, otherwise through the platform mailbox
  • A failed email never fails the submission

File uploads

  • jpg, jpeg, png, gif, webp and pdf are accepted; svg is not
  • Files uploaded by signed-in users are accessible immediately; anonymous uploads are private by default

Orders without online payment

You can take orders without any payment channel, which suits sites that confirm orders manually:

  1. The front end calls /user/api/order_submit with the line items (title, unit price, quantity, optionally a content id) plus contact and shipping details
  2. The order is stored as pending, ready to be reviewed and updated under Orders in the admin console
  3. The site owner gets a new-order notification; the customer gets a receipt (same form_receipt setting as forms)
  4. The customer sees it under My Orders

Line items are a snapshot taken at checkout (title, unit price, quantity), so renaming, repricing or removing a product later never changes past orders. The content id on a line item is only a reference for reporting how much of a product has sold, and may be absent. When a content id is given, the unit price comes from the server's post.price; if it differs from the price the browser sent, the server price wins and the difference is recorded in the order remark.

Set order_auto_pay to yes to send customers straight into payment after checkout (that branch is not open yet). Leaving it unset, or no, keeps the pending-order flow above and never redirects to payment.

Payments

Built on Stripe Checkout (amounts are in cents as well):

  1. The front end calls /user/pay/order_create with the items (content ids and quantities)
  2. Prices are calculated on the server from each item's price — client-supplied prices are never accepted
  3. Redirect to the returned Stripe Checkout URL
  4. After Stripe calls /user/pay/stripe_webhook, the order becomes paid and records the actual amount, tax and shipping details
  5. The customer sees it under My Orders

Fill in your keys in the stripe_config setting and register the webhook URL https://your-domain/user/pay/stripe_webhook in Stripe. Tax is calculated by Stripe Tax from the shipping address; enable it in your Stripe dashboard.

AI & Agent Integration

iHomepage CMS is designed with AI agents in mind: a simple structure, deterministic behavior and consistent APIs, so agents never have to guess.

Why it suits agents

  • An inferable model: one content table, two kinds of relationships, one template selection rule. After reading Core Concepts, an agent can work out where any page comes from and where content belongs.
  • Consistent APIs: every endpoint uses the same response shape and the HTTP status matches code; list endpoints share one set of pagination, sorting and filter parameters.
  • Content and templates are plain text: bodies are HTML, templates are Twig, custom fields are JSON — easy for agents to read, write and diff.
  • Predictable behavior: invalid filter or sort conditions are ignored instead of failing; non-canonical URLs consistently 301; deletion is a recoverable soft delete.
  • Documentation as the spec: this documentation is written for teams and agents alike, in English and Chinese.

What agents can do

TaskHow
Change site settings (phone number, logo, menu)update_settings, after find_text checks whether the text is also hard-coded in a template
Add sections and pagescreate_category_with_items creates a category and its items in one step
Write, update and translate contentcreate_content, update_content, translate_content (URL and language follow the multilingual rules)
Edit templatesupdate_template, with a Twig syntax check before saving
Maintain SEOTitles, descriptions, URLs and translations through the content tools
Read public content/user/api/post_list and /user/api/post_get, no sign-in required

How to connect

Every site has a built-in MCP server (Model Context Protocol, the connection standard supported by Claude, ChatGPT, Cursor and other major agents). There are no keys to copy:

  1. In the admin console, open Agent access and click Generate metadata once
  2. In your agent, add a custom connector (MCP server) with the URL https://your-domain/mcp
  3. The agent opens this site's authorization page: sign in with an administrator account and choose the permissions to allow
  4. Then ask in plain language, for example "change the phone number to 626-000-0000" or "add a Services section describing our three services"
PermissionLets the agent
Read (required)View content, templates and site settings
Manage contentCreate, edit and delete content and categories, and create translations
Change settingsSite name, logo, contact details, menu
Edit templatesChange database templates, syntax-checked before saving
Regenerate pagesRegenerate static pages by URL

Changes made by an agent go live immediately and behave exactly like an administrator saving in the console: the same sanitizing, relationship syncing and static publishing. The authorizing account's admin status is re-checked on every call. You can review and revoke authorizations at any time under Agent access, and revocation takes effect immediately. Credentials such as SMS, email and payment settings are never visible to agents.

Admin Console

Getting in

  1. Add the user panel script to your pages (Getting Started, step 6)
  2. Sign in as an administrator
  3. Switch to Website Content Management in the panel

Permissions: only the site's administrators can enter. Admin status is checked on every request, so revoking it takes effect immediately; an administrator signed in on site A can only manage site A. The first administrator is set up by the platform; after that, administrators can grant or revoke admin status for other users in the console.

Features

ModuleWhat you can do
DashboardLast 30 days of page views, visitors, new users, new content and crawler visits
ContentCreate, edit, delete (recoverable), set hierarchy, associations and their order
TemplatesEdit Twig templates online
Site SettingsBasic info, SEO, contact details and all other settings
AttachmentsUpload, edit details, delete
CommentsApprove, edit
FormsView, process, delete
OrdersView, update status
UsersCreate, edit, deactivate, grant admin
404 LogMissing URLs and their referrers
Page RefreshRegenerate static pages by URL
Data StructureCheck and upgrade the site database structure
Agent AccessConnection URL, generate authorization metadata, review and revoke authorized apps
.well-knownManage domain verification and protocol discovery files (JSON or plain text)
RedirectsSend old URLs to new ones: create, edit, enable/disable, delete, see hit counts

API Reference

Conventions

  • API paths are relative; call them on your own domain: https://your-domain/user/api/login
  • Parameters can be sent as a query string, form data or a JSON body (JSON takes precedence)
  • Every response has the same shape:
{"code": 200, "data": { }}
{"code": 401, "error": "login_required", "message": "Login Required"}

The HTTP status code matches code. On failure:

  • error is a stable error code. Use it to decide what went wrong and how to tell the user (for example, translated into your site's language);
  • message is a default English description. You can show it as is, but its wording may change, so don't branch on it.

Every failure carries error. Where there is no specific code, a generic one follows the status code: bad_request (400), unauthorized (401), forbidden (403), not_found (404), not_allowed (405), payload_too_large (413), unsupported_media_type (415), too_many_requests (429), server_error (500 etc.), service_unavailable (503).

Error codes of the user API /user/api/*:

GroupCodes
Sign-in statelogin_required not signed in, session_expired, too_many_requests
Accountinvalid_credentials wrong account or password, account_disabled, account_create_failed, user_not_found, email_taken, mobile_taken, userid_taken, old_password_incorrect, password_too_short
Verificationverification_code_required code missing, sms_code_invalid, email_code_invalid, invalid_email, unsupported_area_code phone area code not supported
Deliverysms_not_configured, sms_send_failed, email_not_configured, email_send_failed
Google sign-ingoogle_not_configured, google_unavailable, google_credential_invalid, google_email_missing, google_email_unverified
Content and reactionspost_not_found, comment_empty
Forms, orders and paymentform_unavailable, order_unavailable, order_not_found, order_create_failed, invalid_order_items, product_unavailable, payment_not_configured, payment_failed
Uploadsupload_failed, file_too_large, file_type_not_allowed
Parametersmissing_parameter, invalid_parameter (see message for which one)

User API /user/api/*

EndpointSign-inDescription
sessionNoCurrent sign-in state and user info
joinNoSign up
login / logoutNoSign in / sign out
login_googleNoGoogle sign-in
get_profile / edit_profileYesRead / update profile
edit_passwdYesChange password
password_resetNoReset password with an email code
send_email_code / send_sms_codeNoSend a verification code
update_email / update_mobileYesChange email / mobile
comments_submitYesSubmit a comment
like / unlike / favorite / unfavoriteYesLikes and favorites
like_statusNoBatch like/favorite state and counts
subscribeNoEmail subscription
form_submitNoSubmit a form
order_submitYesPlace an order without online payment (stored as pending)
put_fileNoUpload a file
post_list / post_getNoList and detail of published content
orders_list / orders_getYesMy orders

Content list example:

GET /user/api/post_list?where[taxonomy]=product&keyword=chair&page=1&page_size=20

post_list parameters:

ParameterDescription
whereExact match on taxonomy, parent_id, lang, slug and the featured flags; other fields are ignored
keywordMatches title and description
relatedOptional filter by relations, in the grouped form of the template function list_related: [[101, 102], [205]] means listed under 101 or 102, and under 205. Empty groups are ignored; at most 10 groups and 100 ids
orderOptional, written like the sort argument of the template functions; when given, id descending is appended as a tie-breaker
page / page_sizePagination, page_size at most 100

where, order and related can each be sent in three equivalent ways: in a JSON body; with brackets in the query string (where[taxonomy]=firm); or as a JSON string in the query string (where={"taxonomy":"firm"}). Invalid JSON returns 400 instead of silently returning unfiltered content.

With related, the default order is the same as the template function list_related, so a list page can use it for checkbox filters with Ajax refresh and stay in the same order as the first render:

POST /user/api/post_list
{"where": {"taxonomy": "firm", "lang": "cn"}, "related": [[101, 102], [205]], "page": 1, "page_size": 15}
{"code": 200, "data": {"list": [], "total": 0, "page": 1, "page_size": 20}}

Payment API /user/pay/*

EndpointAuthDescription
order_createSigned inCreate an order and return the Stripe Checkout URL checkout_url
stripe_webhookStripe signatureStripe callback

Admin API /cms/api/*

Requires site administrator permissions and powers the admin console. Covers content, templates, settings, attachments, comments, forms, orders, users, redirects, statistics and static page refresh. List endpoints share pagination, sorting and filter parameters:

{"_page": 1, "_page_size": 20, "_order": {"id": "DESC"}, "_filter": {"status": 2, "title[~]": "keyword"}}
  • _page_size is capped at 200
  • Filter operators: equals (no suffix), !, >, <, >=, <=, ~ (contains), !~, <> (range)
  • Unknown fields and invalid conditions are ignored

Configuration Reference

Site settings are maintained under Site Settings in the admin console. JSON settings take JSON text.

Basics

SettingDescriptionDefault
site_nameSite name
domainCanonical domainThe requested domain
schemeProtocolhttps
logoLogo URL
menuMenu data for templates
home_title / home_descriptionHome page title and description
URL_ENDINGEnding of generated URLs, / or .html/
page_sizeItems per list page15
site_blockedyes blocks the whole site: every URL returns only site_message (maintenance / closed mode)no
site_messageNotice shown while blocked; only takes effect when site_blocked is yes. If left empty, a generic "temporarily unavailable" line is shown

Feature switches

SettingDescriptionDefault
languagesMultilingual configuration (JSON), see MultilingualOff
static_publishyes enables static publishingOff
join_need_check1 requires approval for new sign-upsActive immediately

Contact & notifications

SettingFormatDescription
contact_info{"email": "...", "name": "...", "mobile": "...", "address": "..."}Contact details; email receives form notifications
sendmail_smtp{"host","port","secure","username","password","email","name"}The site's own mail account: verification codes, form receipts
form_receipt{"enabled": true, "content": "..."}Send a receipt to form submitters
sms_config{"apiUrl","apiKey","tplId"}SMS verification codes. Leave empty to use the shared platform gateway; only set this if you bring your own
user_agreement_urlSite-relative pathLink behind "User agreement" in the sign-up box; defaults to /user-agreement/
privacy_policy_urlSite-relative pathLink behind "Privacy policy" in the sign-up box; defaults to /privacy-policy/
panel_hide_registeryes / noyes hides the "create an account" link inside the sign-in box. For sites where visitors must complete your own flow first — registration is then only reachable from the register button on your pages. Allowed by default

Payments & sign-in

SettingFormatDescription
order_auto_payyes / noyes sends customers into payment right after checkout; unset or no leaves orders pending for manual review and only sends the notification emails
stripe_config{"secret_key","webhook_secret","success_url","cancel_url","tax_countries"}Stripe payments; redirect URLs may contain the {ORDER_NO} placeholder
social_loginJSONSocial sign-in settings

Credential settings (SMTP, SMS, Stripe, social sign-in) are never shown in plain text after saving.

Security

The platform has these protections built in; sites must follow the matching rules.

Platform protectionWhat you need to do
Body HTML is sanitized against an allow-list on save: script, style, iframe, form and similar are removed, event attributes are strippedPut interactivity and styles in templates, not in content bodies. Bodies hook into template styles via class (kept on all formatting tags); use native <details>/<summary> for collapsible content
Templates don't auto-escapeNever use |raw on untrusted data
Credentials live only in the site's settings and are never echoed backNever put keys in templates, front-end code or public files
Admin permissions are checked on every request; sites are isolated from each otherRevoke admin rights that are no longer needed
Uploads use an extension allow-list, reject SVG, and anonymous uploads are private by defaultDon't build separate upload endpoints
Payment amounts are calculated on the server; webhooks verify signaturesNever calculate or send prices from the front end
Sign-in, sign-up, verification code and form endpoints are rate-limitedDon't build separate sign-in or form endpoints
Deletion is soft; associations are keptTo restore, change the status back
Static publishing only writes safe file types and pathsDon't edit generated static pages by hand

Responsibilities & Rules

The CMS foundation and the websites built on it evolve independently. Clear boundaries let the foundation upgrade safely and let your site develop freely.

Boundary principles

  • Platform matters are not the site's to change. The core, data structure, rendering and routing rules, static publishing and platform APIs are maintained by the platform.
  • Site matters are not the platform's to change — it only advises. Content, templates, design, settings and standalone functionality are decided by the site.
  • When you're unsure who owns something, talk first, act second.

Who is responsible for what

AreaOwner
The content database structure (tables, fields, indexes)Platform
Page rendering, routing rules, template functionsPlatform
Platform APIs (users, payments, admin)Platform
Static publishing and the files it generatesPlatform
The data in the content database: content, templates, settings, users, forms, ordersSite
Files the site placed in its public/Site
The site's templates/ directorySite
The site's own databases, scripts and standalone appsSite

In one sentence: the database structure belongs to the platform; the data belongs to the site.

The platform's commitments

  • Never modify a site's content, templates or settings
  • Never overwrite or delete files the platform didn't generate
  • The data structure only grows, never shrinks, and is fully backed up before upgrades
  • Upgrades don't change existing page output by default; when they do, affected sites are notified in advance with what they need to do
  • When the platform spots a problem in a site, it advises; the site decides whether to change it

Rules for sites

Free to do:

  • Define any content types and structures (taxonomy + parent_id + associations + meta_json)
  • Write any templates and design any pages
  • Put your own pages, assets and front-end apps in public/
  • Build your own databases and standalone features
  • Decide whether to enable static publishing and multilingual, and all other settings

Don't:

  • ❌ Change the content database structure (add or change fields, create tables with the same name)
  • ❌ Hard-code content ids in templates
  • ❌ Use |raw on untrusted data
  • ❌ Edit generated static pages by hand
  • ❌ Implement paths that clash with platform APIs, or rebuild sign-in, forms, uploads or payments yourself
  • ❌ Store the same relationship in both parent_id and associations
  • ❌ Put credentials in templates or front-end code
  • ❌ Create an empty content database to avoid 404s
  • ❌ Write to the content database directly, bypassing the platform APIs. Create, update and delete content through the admin API or agent integration; direct writes skip publishing, pages won't be updated to match, and any resulting inconsistency is the site's responsibility

Extending & Feature Requests

Try what already exists

Most needs don't require platform changes:

NeedApproach
A new fieldmeta_json
A new content typeNew taxonomy + template with the same name
A new relationshipNew type combination + association
A special layout for one pageSet a dedicated template in the item's template field
Complex listsCombine conditions and sorting in list_post / list_related
Standalone functionalityA standalone app in the site's public/

The generality principle

The platform doesn't change for a single site. A request can become part of the platform only if all of these are true:

  1. Several different kinds of sites would use it
  2. It can be described without naming any specific site or business
  3. It doesn't change any existing site's behavior (off by default, or invisible to sites that don't use it)
  4. It is a mechanism, not a business decision — the platform can offer "sort by any field", never "put the bestsellers first"

How to make a request

Include:

  • The problem to solve (not a solution you've already picked)
  • Why existing capabilities can't do it, or only at too high a cost
  • Which other kinds of sites would need it
  • The template syntax or API shape you'd expect
  • Your current workaround

The answer you'll get

  • Accepted: turned into a general capability in the platform, and this documentation is updated.
  • Not accepted: with the reason, plus concrete advice on building it with existing capabilities.

The platform never adds a switch just for one site.

Launch Checklist

Pages

  • ☐ Home, every content type's pages, search and 404 work
  • ☐ Every page takes its title, description and canonical from base
  • ☐ <html lang> is correct; multilingual sites have complete hreflang
  • ☐ List page URLs end with /, pagination works, out-of-range pages return 404
  • ☐ No CSS, JS or image 404s on nested URLs
  • ☐ Mobile and desktop look right; no errors in the browser console

Content

  • ☐ Public content is published, with an explicit and unique url
  • ☐ Hierarchy and association directions are correct
  • ☐ No hard-coded ids in templates
  • ☐ Custom data is in meta_json
  • ☐ robots.txt and sitemap.xml are reachable

Features & security

  • ☐ The user panel signs in and administrators can open the console
  • ☐ Forms submit and the site owner receives notifications
  • ☐ No |raw on untrusted data
  • ☐ Credentials exist only in site settings
  • ☐ Sites with static publishing have had a full rebuild, and another one after template changes

FAQ

I changed a template but the page didn't change

With static publishing, only the home page updates automatically after a template change; other pages need a full rebuild. If it still doesn't change, check whether a file template with the same name in templates/ is hiding the database template.

A list is empty

Check in order: is the content published; does the association exist in the right direction (owner first); is the template using a stale hard-coded id; is a sort field misspelled.

Opening an item redirects (301) to another URL

You requested a URL that isn't its canonical URL. This prevents duplicate indexing and is expected.

I want the same item ordered differently in different categories

Use the association's weight: list_related(category_id, 10, 1, 'weight').

I want to add a field to content

Use meta_json. If every site needs it, raise it under Extending & Feature Requests.

"Template not found" or "template engine" errors

Check that the template name matches the item's template or taxonomy (the .html suffix is optional), and that database templates use the twig engine.

Errors when a template contains {{ or {%

These are Twig syntax in templates. To output them literally, wrap them in {% verbatim %}…{% endverbatim %}. Content bodies are not affected.

A site without a content database returns 404 for missing paths

Expected behavior; nothing to fix.

Switching language on a multilingual site took me to the home page

That language has no translation of the page yet. Add it following the URI convention.

Glossary

TermMeaning
Content (post)Every data unit in the CMS: articles, products, categories, tags…
taxonomyContent type, defined freely by each site
Hierarchy (parent_id)One-to-one membership
Association (post_post)Many-to-many relationship with an owner, a member and a sort weight
Owner / memberThe two ends of an association; in "category → article" the category is the owner
meta_jsonCustom fields on content
Canonical URLThe item's url, its single public address
TemplateA Twig file or database template that decides how content is displayed
base_layoutOptional site-wide page shell
Static publishingPre-generating pages as static files
Full rebuildRegenerating every static page of the site
Site settings (options)Site-level configuration

This page is itself built with iHomepage CMS: it is document content in the official site's content database, available in English and Chinese at the same URI (/documents/ and /cn/documents/), rendered by the template document.html, with the table of contents taken from the._toc, and statically published.

Start from a structure that already works

Every iHomepage CMS template is built on the content model described here — pick one for your industry and start publishing.

Browse templates