
Security News
GitHub Actions Pricing Whiplash: Self-Hosted Actions Billing Change Postponed
GitHub postponed a new billing model for self-hosted Actions after developer pushback, but moved forward with hosted runner price cuts on January 1.
Welcome to FormCMS! 🚀
Our mission is to make data modeling, backend development, and frontend development as simple and intuitive as filling out a form 📋
We’d love for you to contribute to FormCMS! Check out our CONTRIBUTING guide to get started.
Love FormCMS? Show your support by giving us a ⭐ on GitHub and help us grow! 🌟
Have suggestions? Report an Issue.
FormCMS is an open-source Content Management System designed to simplify and accelerate web development workflows for CMS projects and general web applications. It streamlines data modeling, backend development, and frontend design, making them as intuitive as filling out a form. With a focus on fostering user engagement, FormCMS provides robust social features alongside powerful tools for data management, API development, and dynamic page creation.
FormCMS offers intuitive data modeling and built-in RESTful APIs for Create, Read, Update, and Delete (CRUD) operations. These are complemented by a React-based admin panel, enabling seamless data management for developers and content creators alike.
Harness the power of GraphQL to fetch multiple related entities in a single query, enhancing client-side performance, security, and flexibility. The integrated Grapes.js page designer, powered by Handlebars, allows effortless creation of dynamic, data-bound pages, simplifying the design process.
FormCMS enhances user interaction with built-in social capabilities:
Most CMS solutions support entity customization and adding custom properties, but they implement these changes in three distinct ways:
In contrast, FormCMS adopts a normalized, structured data approach, where each property is mapped to a corresponding table field:
Many GraphQL frameworks support persisted queries with GET requests, enabling caching and improved performance.
FormCMS automatically saves GraphQL queries and converts them into RESTful GET requests. For example:
query TeacherQuery($id: Int) {
teacherList(idSet: [$id]) {
id firstname lastname
skills { id name }
}
}
becomes GET /api/queries/TeacherQuery.
By transforming GraphQL into optimized REST-like queries, FormCMS ensures a secure, efficient, and scalable API experience.
admin@cms.comAdmin1!example code can be found at /formCMS/examples
Defult login:
samdmin@cms.comAdmin1!After login to Admin Panel, you can go to Tasks, click Import Demo Data, to import demo data.
FormCMS is designed with performance, scalability, and extensibility as core principles.
FormCMS was built to address three critical concerns for modern CMS platforms:
FormCMS achieves performance comparable to specialized GraphQL engines while maintaining full CMS functionality.
| Platform | List 20 Posts (P95) | Throughput | Filter by Tag (P95) | Throughput |
|---|---|---|---|---|
| FormCMS | 48 ms | 2,400 QPS | 55 ms | 2,165 QPS |
| Hasura | 48 ms | 2,458 QPS | 53 ms | 2,056 QPS |
| Orchard Core | 2.3 s | 30 QPS | — | — |
| Operation | P95 Latency | Throughput |
|---|---|---|
| Read operations | 187 ms | ~1,000 QPS |
| Buffered writes | 19 ms | ~4,200 QPS |
Most CMS platforms use key-value storage or JSON documents for flexibility, sacrificing performance. FormCMS takes a different approach:
Traditional CMS (Key-Value Storage):
+------------------+
| ContentItemId |
| PropertyName | ← No indexes, slow joins
| PropertyValue |
+------------------+
FormCMS (Normalized Tables):
+------------------+
| id | ← Primary key
| title | ← Indexed
| publishedAt | ← Indexed
| categoryId | ← Foreign key
+------------------+
Benefits:
Schema Caching:
Data Caching:
For user activity data (likes, views, shares):
FormCMS separates cacheable CMS content from high-volume user data for independent scaling.
CDN Layer
↓
App Nodes (with local cache)
↓
Distributed Cache (Redis)
↓
Primary Database
Characteristics:
App Nodes
↓
Shard Router (MD5 hash by userId)
↓
+----------+ +----------+ +----------+
| Shard 1 | | Shard 2 | | Shard N |
| ~100M | | ~100M | | ~100M |
| records | | records | | records |
+----------+ +----------+ +----------+
Characteristics:
Using The New York Times scale (639M monthly visits):
FormCMS is designed as a reusable framework rather than a standalone application.
Install FormCMS in any ASP.NET Core project:
dotnet add package FormCMS
// Minimal setup in your existing project
builder.Services.AddPostgresCms(connectionString);
builder.Services.AddCmsAuth<CmsUser, IdentityRole, CmsDbContext>(authConfig);
await app.UseCmsAsync();
Inject custom business logic at key lifecycle points:
// Before/after entity operations
hookRegistry.Register(HookPointType.EntityPreAdd, async (args, ct) => {
var entity = args.Entity;
var record = args.Record;
// Custom validation, transformation, or side effects
return Result.Ok();
});
hookRegistry.Register(HookPointType.EntityPostUpdate, async (args, ct) => {
// Trigger workflows, send notifications, etc.
return Result.Ok();
});
Available Hooks:
SchemaPreGetAll, SchemaPostSaveEntityPreAdd, EntityPostUpdate, EntityPreDel, EntityPostDelQueryPreList, QueryPostListAssetPreAdd, AssetPostDeleteEvent-driven architecture for loosely coupled systems:
// Publish CRUD events
builder.Services.AddCrudMessageProducer(["course", "lesson"]);
// Subscribe to events in separate workers
public class CustomEventHandler : IHostedService
{
public async Task HandleCourseCreated(CourseCreatedEvent evt)
{
// Update search index, send notifications, etc.
}
}
Benefits:
Each feature owns its data and can be enabled/disabled independently:
// Enable only the features you need
builder.Services.AddCmsAuth<CmsUser, IdentityRole, CmsDbContext>(authConfig);
builder.Services.AddEngagement(enableBuffering: true, shardConfigs);
builder.Services.AddComments(commentShards);
builder.Services.AddNotify(notifyShards);
builder.Services.AddSearch(ftsProvider, connString);
builder.Services.AddSubscriptions(); // Stripe integration
builder.Services.AddVideo(); // HLS video processing
Benefits:
FormCMS architecture supports diverse use cases:
The combination of normalized data modeling, intelligent caching, horizontal sharding, and modular architecture enables FormCMS to scale from prototype to production while maintaining developer productivity.
This section provides detailed guidance on developing a foundational online course system, encompassing key entities: teacher, course, lesson,skill, and material.
The Teachers table maintains information about instructors, including their personal and professional details.
| Field | Header | Data Type |
|---|---|---|
id | ID | Int |
firstname | First Name | String |
lastname | Last Name | String |
email | String | |
phone_number | Phone Number | String |
image | Image | String |
bio | Bio | Text |
The Courses table captures the details of educational offerings, including their scope, duration, and prerequisites.
| Field | Header | Data Type |
|---|---|---|
id | ID | Int |
name | Course Name | String |
status | Status | String |
level | Level | String |
summary | Summary | String |
image | Image | String |
desc | Description | Text |
duration | Duration | String |
start_date | Start Date | Datetime |
The Lessons table contains detailed information about the lessons within a course, including their title, content, and associated teacher.
| Field | Header | Data Type |
|---|---|---|
id | ID | Int |
name | Lesson Name | String |
description | Description | Text |
teacher | Teacher | Int (Foreign Key) |
course | Course | Int (Foreign Key) |
created_at | Created At | Datetime |
updated_at | Updated At | Datetime |
The Skills table records competencies attributed to teachers.
| Field | Header | Data Type |
|---|---|---|
id | ID | Int |
name | Skill Name | String |
years | Years of Experience | Int |
created_by | Created By | String |
created_at | Created At | Datetime |
updated_at | Updated At | Datetime |
The Materials table inventories resources linked to courses.
| Field | Header | Data Type |
|---|---|---|
id | ID | Int |
name | Name | String |
type | Type | String |
image | Image | String |
link | Link | String |
file | File | String |
After launching the web application, locate the Schema Builder menu on the homepage to start defining your schema.
Course entity, add attributes such as name, status, level, and description.To establish a many-to-one relationship between the Course and Teacher entities, you can include a Lookup attribute in the Course entity. This allows selecting a single Teacher record when adding or updating a Course.
| Attribute | Value |
|---|---|
| Field | teacher |
| DataType | Lookup |
| DisplayType | Lookup |
| Options | Teacher |
Description: When a course is created or modified, a teacher record can be looked up and linked to the course.
To establish a one-to-many relationship between the Course and Lesson entities, use a Collection attribute in the Course entity. This enables associating multiple lessons with a single course.
| Attribute | Value |
|---|---|
| Field | lessons |
| DataType | Collection |
| DisplayType | EditTable |
| Options | Lesson |
Description: When managing a course , you can manage lessons of this course.
To establish a many-to-many relationship between the Course and Material entities, use a Junction attribute in the Course entity. This enables associating multiple materials with a single course.
| Attribute | Value |
|---|---|
| Field | materials |
| DataType | Junction |
| DisplayType | Picklist |
| Options | Material |
Description: When managing a course, you can select multiple material records from the Material table to associate with the course.
The last chapter introduced how to model entities, this chapter introduction how to use Admin-Panel to manage data of those entities.
The Admin Panel supports various UI controls to display attributes:
"text": Single-line text input.
"textarea": Multi-line text input.
"editor": Rich text input.
"dictionary": Key-Value pairs
"number": Single-line text input for numeric values only.
"localDatetime": Datetime picker for date and time inputs, displayed as the browser's timezone.
"datetime": Datetime picker for date and time inputs.
"date": Date picker for date-only inputs.
"image": Upload a single image, storing the image URL.
"gallery": Upload multiple images, storing their URLs.
"file": Upload a file, storing the file URL.
"dropdown": Select an item from a predefined list.
"multiselect": Select multiple items from a predefined list.
"lookup": Select an item from another entity with a many-to-one relationship (requires Lookup data type).
"treeSelect": Select an item from another entity with a many-to-one relationship (requires Lookup data type), items are organized as tree.
"picklist": Select multiple items from another entity with a many-to-many relationship (requires Junction data type).
"tree": Select multiple items from another entity with a many-to-many relationship (requires Junction data type), items are organized as a tree.
"edittable": Manage items of a one-to-many sub-entity (requires Collection data type).
See this example how to configure entity category, so it's item can be organized as tree.
Below is a mapping of valid DataType and DisplayType combinations:
| DataType | DisplayType | Description |
|---|---|---|
| Int | Number | Input for integers. |
| Datetime | LocalDatetime | Datetime picker for local datetime. |
| Datetime | Datetime | Datetime picker for date and time inputs. |
| Datetime | Date | Date picker for date-only inputs. |
| String | Number | Input for numeric values. |
| String | Datetime | Datetime picker for date and time inputs. |
| String | Date | Date picker for date-only inputs. |
| String | Text | Single-line text input. |
| String | Textarea | Multi-line text input. |
| String | Image | Single image upload. |
| String | Gallery | Multiple image uploads. |
| String | File | File upload. |
| String | Dropdown | Select an item from a predefined list. |
| String | Multiselect | Select multiple items from a predefined list. |
| Text | Multiselect | Select multiple items from a predefined list. |
| Text | Gallery | Multiple image uploads. |
| Text | Textarea | Multi-line text input. |
| Text | Editor | Rich text editor. |
| Text | Dictionary | Key-Value Pair |
| Lookup | Lookup | Select an item from another entity. |
| Lookup | TreeSelect | Select an item from another entity. |
| Junction | Picklist | Select multiple items from another entity. |
| Lookup | Tree | Select multiple items from another entity. |
| Collection | EditTable | Manage items of a sub-entity. |
The List Page displays entities in a tabular format, supporting sorting, searching, and pagination for efficient browsing or locating of specific records.
Sort records by clicking the ↑ or ↓ icon in the table header.
Apply filters by clicking the Funnel icon in the table header.
Clicking the duplicate button opens the "Add New Data" page with prefilled values from the selected record for quick entry.
Detail page provides an interface to manage single record.
date,image, gallery, muliselect, dropdown,lookup,picklist,edittableThis feature allows content creators to plan and organize their work, saving drafts for later completion.
Content can have one of the following publication statuses:
draftscheduledpublishedunpublishedOnly content with the status published can be retrieved through GraphQL queries.
When defining an entity in the Schema Builder, you can configure its default publication status as either draft or published.
On the content edit page, you can:
By default, only published content appears in query results.
If you want to preview how the content looks on a page before publishing, you can add the query parameter preview=1 to the page URL.
For a more convenient approach, you can set the Preview URL in the Entity Settings page. Example Entity Settings Page
The Publication Worker automates the process of updating scheduled items in batches, transitioning them to the published status at the appropriate time.
Protect user from dirty write(concurrent update)
Return the updated_at timestamp when fetching the item. When updating, compare the stored updated_at with the one in the request. If they differ, reject the update
If a concurrent modification is detected, the system will throw the following exception:
"Error: Concurrent Write Detected. Someone else has modified this item since you last accessed it. Please refresh the data and try again."
Audit logging in FormCMS helps maintain accountability and provides a historical record of modifications made to entities within the system.
An audit log entry captures essential information about each modification. The entity structure includes the following fields:
An audit log entry is created when a user performs any of the following actions:
Audit logs can be accessed and searched by users with appropriate permissions. The following roles have access:
These users can:
By maintaining a detailed audit trail, the System enhances security and operational efficiency, ensuring that all modifications are tracked and can be reviewed when necessary.
The Asset Library centralizes the management of uploaded assets (e.g., images, files), supporting both local and cloud storage. It enables reuse, optimizes storage, and provides robust permissions and extensibility for various cloud providers.
Assets are stored in a repository, each identified by a unique Path (e.g., 2025-03/abc123, where 2025-03 is a folder based on yyyy-MM and abc123 is a ULID) and a fixed Url (e.g., /files/2025-03/abc123 or a cloud-specific URL). Relationships to data entities are tracked via AssetLink records. The system supports local storage by default and integrates with cloud storage providers (e.g., Azure Cloud Storage) via the IFileStore interface. For images, uploads are resized to a default maximum width of 1200 pixels and a compression quality of 75, configurable in SystemSettings.
In forms with image, file, or gallery fields, users can:
IFileStore.Upload. The system generates a unique Path (e.g., 2025-03/abc123), sets a Url (local or cloud-based), and records metadata (Name, Size, Type, CreatedBy, CreatedAt). Images are resized (max width: 1200px, quality: 75) before saving to the chosen storage provider in the 2025-03 folder. A default Title is derived from Name.
Gallery View: Thumbnails for images.List View: Table with Name, Title, Size, CreatedAt, and Type. Filterable by keyword, size range, or date range; sortable in ascending/descending order.LinkCount and adding an AssetLink.LinkCount.Title or metadata.The Asset List Page lists assets with Name, Title, Size, Type, CreatedAt, and LinkCount. Assets with LinkCount of 0 (orphans) can be deleted via IFileStore.Del, removing them from storage (e.g., the 2025-03 folder) and the system.
On the Asset Detail Page, users can replace content:
IFileStore.Upload to the same Path (e.g., 2025-03/abc123), updating Size, Type, and UpdatedAt.Path and Url remain unchanged, ensuring continuity for linked entities.On the Asset Detail Page, users can modify:
Name).{"AltText": "Description"}), updating UpdatedAt.The Asset Library supports cloud storage through the IFileStore interface, with Azure Cloud Storage as an example. Other providers (e.g., Google Cloud Storage, AWS S3) can be integrated by implementing this interface and registering it in the dependency injection container.
IFileStore Interfacenamespace FormCMS.Infrastructure.FileStore;
public record FileMetadata(long Size, string ContentType);
public interface IFileStore
{
Task Upload(IEnumerable<(string, IFormFile)> files, CancellationToken ct);
Task Upload(string localPath, string path, CancellationToken ct);
Task<FileMetadata?> GetMetadata(string filePath, CancellationToken ct);
string GetUrl(string file);
Task Download(string path, string localPath, CancellationToken ct);
Task Del(string file, CancellationToken ct);
}
2025-03/abc123).Size and ContentType.https://<account>.blob.core.windows.net/<container>/2025-03/abc123 for Azure).To use Google Cloud Storage, AWS S3, or others:
IFileStore with provider-specific logic (e.g., S3’s PutObject for uploads to 2025-03/abc123).services.AddScoped<IFileStore, AwsS3FileStore>()).2025-03 folder (e.g., 2025-03/abc123).https://<account>.blob.core.windows.net/<container>/2025-03/abc123.A role-based system controls asset access:
2025-03).Permissions filter the library dialog and validate actions against ownership and storage location.
ImageCompressionOptions):
MaxWidth: Default 1200px.Quality: Default 75 (0-100)./files (e.g., /files/2025-03/abc123); cloud URLs depend on the provider (via IFileStore.GetUrl).2025-03) optimize usage; cloud storage scales capacity.Url ensures seamless updates.LinkCount and AssetLink monitor usage.Title as alt text enhances image discoverability.IFileStore support growing storage demands.FormCMS takes measures to reduce security vulnerabilities
By default, ASP.NET Core buffers uploaded files entirely in memory, which can lead to excessive memory consumption. FormCMS restricts individual file uploads to a default size of 10MB. This limit is configurable.
// Set max file size to 15MB
builder.AddSqliteCms(databaseConnectionString, settings => settings.MaxRequestBodySize = 1024 * 1024 * 15),
For files exceeding the maximum size limit, FormCMS supports chunked uploading. The client uploads the file in 1MB chunks.
FormCMS supports both local and cloud-based file storage.
By default, uploaded files are saved to <app>/wwwroot/files.
However, this default setting may present some challenges:
You can configure a different path for file storage as shown below:
builder.AddSqliteCms(databaseConnectionString, settings => settings.LocalFileStoreOptions.PathPrefix = "/data/"),
FormCMS allows uploading only specific file types: 'gif', 'png', 'jpeg', 'jpg', 'zip', 'mp4', 'mpeg', 'mpg'.
It verifies the binary signature of each file to prevent spoofing.
You can extend the file signature dictionary as needed.
public Dictionary<string, byte[][]> FileSignature { get; set; } = new()
{
{
".gif", [
"GIF8"u8.ToArray()
]
},
{
".png", [
[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]
]
},
The Date and Time system in FormCMS manages how dates and times are displayed and stored, supporting three distinct formats: localDatetime, datetime, and date. It ensures accurate representation across time zones and consistent storage in the database.
FormCMS provides three display formats for handling date and time data, each serving a specific purpose:
localDatetime: Displays dates and times adjusted to the user's browser time zone (e.g., a start time that varies by location).
datetime: Zone-agnostic, showing the same date and time universally (e.g., a fixed event time).
date: Zone-agnostic, displaying only the date without time (e.g., a birthday).
localDatetimedatetime to the browser's time zone for display. For example, an event starting at 2025-03-19 14:00 UTC would appear as 2025-03-19 09:00 EST for a user in New York (UTC-5) or 2025-03-19 23:00 JST for a user in Tokyo (UTC+9).2025-03-19 09:00 EST is stored as 2025-03-19 14:00 UTC.datetime2025-03-19 14:00 is displayed as 2025-03-19 14:00 everywhere.date2025-03-19), with no time component or zone consideration.datetime with the time set to 00:00:00 (midnight), typically in UTC for consistency, but the time portion is ignored in display.CreatedAt, UpdatedAt) are stored as UTC datetime values (e.g., 2025-03-19 14:00:00Z). This ensures a universal reference point for auditing and synchronization.localDatetime Handling:datetime Handling: Stored and retrieved as entered, with no conversion, assuming it’s a fixed point in time.date Handling: Stored as a datetime with the time component set to 00:00:00 (e.g., 2025-03-19 00:00:00Z), though only the date part is used in display.Event Start (localDatetime):
2025-03-19 09:00 EST.2025-03-19 14:00:00Z (UTC).2025-03-19 23:00 JST.Log Entry (datetime):
2025-03-19 14:00.2025-03-19 14:00.Birthday (date):
2025-03-19.2025-03-19 00:00:00Z.2025-03-19.localDatetime adapts to user locations, while datetime and date provide universal clarity.This feature allows you to export or import data
This feature is helpful for the following scenarios:
Tasks.Add Export Task.Tasks.Add Import Task, then select the zip file you wish to import.FormCms' modular component structure makes it easy to modify UI text, replace components, or swap entire pages.
The FormCms Admin Panel is built with React and split into two projects:
FormCmsAdminSdk
This SDK handles backend interactions and complex state management. It is intended to be a submodule of your own React App. It follows a minimalist approach, relying only on:
"react", "react-dom", "react-router-dom": Essential React and routing dependencies."axios" and "swr": For API access and state management."qs": Converts query objects to strings."react-hook-form": Manages form inputs.FormCmsAdminApp
A demo implementation showing how to build a React app with the FormCmsAdminSdk. Fork this project to customize the layout, UI text, or add features.
A Git submodule embeds an external repository (e.g., FormCmsAdminSdk) as a subdirectory in your project. Unlike NPM packages, which deliver bundled code, submodules provide the full, readable source, pinned to a specific commit. This offers flexibility for customization, debugging, or upgrading the SDK directly in your repository.
To update a submodule:
git submodule update --remote
Then commit the updated reference in your parent repository.
To create a custom AdminPanelApp with submodules, start with the example repo FormCmsAdminApp:
git clone --recurse-submodules https://github.com/FormCms/FormCmsAdminApp.git
The --recurse-submodules flag ensures the SDK submodule is cloned alongside the main repo.
cd FormCmsAdminApp
pnpm install
Start the formCms backend, you might need to modify .env.development, change the Api url to your backend.
VITE_REACT_APP_API_URL='http://127.0.0.1:5000/api'
Start the React App
pnpm dev
After customizing, build your app:
pnpm build
Copy the contents of the dist folder to <your backend project>\wwwroot\admin to replace the default Admin Panel.
The SDK (FormCmsAdminSdk) includes an integrated router and provides three hooks for menu items:
useEntityMenuItemsuseAssetMenuItemsuseSystemMenuItemsUse these to design your app’s layout and update the logo within this structure.
Each page (a root-level component tied to the router) can use a corresponding use***Page hook from the SDK. These hooks handle state and API calls, returning components for your UI.
To customize text:
pageConfig argument in the hook.componentConfig argument.Replace table columns, input fields, or other UI components with your custom versions as needed.
FormCMS's video processing plugin converts MPEG files to HLS format, enabling seamless online video streaming.
The video processing plugin can be deployed as a standalone node for scalability or deployed to the same node as the web app for simplicity.
Web Apps (n) ┌──→ NATS Message Broker ───→ Video Processing Apps (m)
│ │
└────────→ Cloud Storage ◄────────┘
Web App ───→ Channel ───→ Video Processing Worker
│ │
└──────→ Storage (Local/Cloud) ◄──────┘
Upload videos as you would any asset. When the server detects a video file, it triggers a processing event by sending a message.
Upon receiving the message, the plugin:
.m3u8 playlist and segmented video files.Integrate videos into your site using the Grape.js Video component:
{{video_field_name.url}} for seamless playback.FormCMS simplifies frontend development by offering robust GraphQL support.
To get started, launch the web application and navigate to /graph. You can also try our online demo.
For each entity in FormCMS, two GraphQL fields are automatically generated:
<entityName>: Returns a record.<entityNameList>: Returns a list of records.**Single Course **
{
course {
id
name
}
}
**List of Courses **
{
courseList {
id
name
}
}
You can query specific fields for both the current entity and its related entities. Example Query:
{
courseList{
id
name
teacher{
id
firstname
lastname
skills{
id
name
}
}
materials{
id,
name
}
}
}
Value Match in FormCMSFormCMS provides flexible filtering capabilities using the idSet field (or any other field), enabling precise data queries by matching either a single value or a list of values.
Filter by a Single Value Example:
{
courseList(idSet: 5) {
id
name
}
}
Filter by Multiple Values Example:
{
courseList(idSet: [5, 7]) {
id
name
}
}
Operator Match in FormCMSFormCMS supports advanced filtering options with Operator Match, allowing users to combine various conditions for precise queries.
matchAll Example:Filters where all specified conditions must be true.
In this example: id > 5 and id < 15.
{
courseList(id: {matchType: matchAll, gt: 5, lt: 15}) {
id
name
}
}
matchAny Example:Filters where at least one of the conditions must be true.
In this example: name starts with "A" or name starts with "I".
{
courseList(name: [{matchType: matchAny}, {startsWith: "A"}, {startsWith: "I"}]) {
id
name
}
}
Filter Expressions in FormCMSFilter Expressions allow precise filtering by specifying a field, including nested fields using JSON path syntax. This enables filtering on subfields for complex data structures.
Example: Filter by Teacher's Last Name This query returns courses taught by a teacher whose last name is "Circuit."
{
courseList(filterExpr: {field: "teacher.lastname", clause: {equals: "Circuit"}}) {
id
name
teacher {
id
lastname
}
}
}
Sorting by a single field
{
courseList(sort:nameDesc){
id,
name
}
}
Sorting by multiple fields
{
courseList(sort:[level,id]){
id,
level
name
}
}
Sort Expressions allow sorting by nested fields using JSON path syntax.
Example: Sort by Teacher's Last Name
{
courseList(sortExpr:{field:"teacher.lastname", order:Desc}) {
id
name
teacher {
id
lastname
}
}
}
Pagination on root field
{
courseList(offset:2, limit:3){
id,
name
}
}
Try it here
Pagination on sub field
{
courseList{
id,
name
materials(limit:2){
id,
name
}
}
}
Variables are used to make queries more dynamic, reusable, and secure.
Value filterquery ($id: Int!) {
teacher(idSet: [$id]) {
id
firstname
lastname
}
}
Operator Match filterquery ($id: Int!) {
teacherList(id:{equals:$id}){
id
firstname
lastname
}
}
Filter Expressionquery ($years: String) {
teacherList(filterExpr:{field:"skills.years",clause:{gt:$years}}){
id
firstname
lastname
skills{
id
name
years
}
}
}
query ($sort_field:TeacherSortEnum) {
teacherList(sort:[$sort_field]) {
id
firstname
lastname
}
}
query ($sort_order: SortOrderEnum) {
courseList(sortExpr:{field:"teacher.id", order:$sort_order}){
id,
name,
teacher{
id,
firstname
}
}
}
query ($offset:Int) {
teacherList(limit:2, offset:$offset) {
id
firstname
lastname
}
}
If you want a variable to be mandatory, you can add a ! to the end of the type
query ($id: Int!) {
teacherList(id:{equals:$id}){
id
firstname
lastname
}
}
Explore the power of FormCMS GraphQL and streamline your development workflow!
Realtime queries may expose excessive technical details, potentially leading to security vulnerabilities.
Saved Queries address this issue by abstracting the GraphQL query details. They allow clients to provide only variables, enhancing security while retaining full functionality.
OperationName as the Saved Query IdentifierIn FormCMS, the Operation Name in a GraphQL query serves as a unique identifier for saved queries. For instance, executing the following query automatically saves it as TeacherQuery:
query TeacherQuery($id: Int) {
teacherList(idSet: [$id]) {
id
firstname
lastname
skills {
id
name
}
}
}
FormCMS generates two API endpoints for each saved query:
List Records:
https://fluent-cms-admin.azurewebsites.net/api/queries/TeacherQuery
Single Record:
https://fluent-cms-admin.azurewebsites.net/api/queries/TeacherQuery/single/
The Saved Query API allows passing variables via query strings:
Single Value:
https://fluent-cms-admin.azurewebsites.net/api/queries/TeacherQuery/?id=3
Array of Values:
https://fluent-cms-admin.azurewebsites.net/api/queries/TeacherQuery?id=3&id=4
This passes [3, 4] to the idSet argument.
Saved QueryBeyond performance and security improvements, Saved Query introduces enhanced functionalities to simplify development workflows.
offsetBuilt-in variables offset and limit enable efficient pagination. For example:
https://fluent-cms-admin.azurewebsites.net/api/queries/TeacherQuery?limit=2&offset=2
offset Pagination for SubfieldsTo display a limited number of subfield items (e.g., the first two skills of a teacher), use the JSON path variable, such as skills.limit:
https://fluent-cms-admin.azurewebsites.net/api/queries/TeacherQuery?skills.limit=2
cursorFor large datasets, offset pagination can strain the database. For example, querying with offset=1000&limit=10 forces the database to retrieve 1010 records and discard the first 1000.
To address this, Saved Query supports cursor-based pagination, which reduces database overhead.
Example response for https://fluent-cms-admin.azurewebsites.net/api/queries/TeacherQuery?limit=3:
[
{
"hasPreviousPage": false,
"cursor": "eyJpZCI6M30"
},
{
},
{
"hasNextPage": true,
"cursor": "eyJpZCI6NX0"
}
]
If hasNextPage of the last record is true, use the cursor to retrieve the next page:
https://fluent-cms-admin.azurewebsites.net/api/queries/TeacherQuery?limit=3&last=eyJpZCI6NX0
Similarly, if hasPreviousPage of the first record is true, use the cursor to retrieve the previous page:
https://fluent-cms-admin.azurewebsites.net/api/queries/TeacherQuery?limit=3&first=eyJpZCI6Nn0
Subfields also support cursor-based pagination. For instance, querying https://fluent-cms-admin.azurewebsites.net/api/queries/TeacherQuery?skills.limit=2 returns a response like this:
[
{
"id": 3,
"firstname": "Jane",
"lastname": "Debuggins",
"hasPreviousPage": false,
"skills": [
{
"hasPreviousPage": false,
"cursor": "eyJpZCI6MSwic291cmNlSWQiOjN9"
},
{
"hasNextPage": true,
"cursor": "eyJpZCI6Miwic291cmNlSWQiOjN9"
}
],
"cursor": "eyJpZCI6M30"
}
]
To fetch the next two skills, use the cursor:
https://fluent-cms-admin.azurewebsites.net/api/queries/TeacherQuery/part/skills?limit=2&last=eyJpZCI6Miwic291cmNlSWQiOjN9
Below is a rewritten version of the Asset Type and Distinct chapters from your GraphQL Query documentation. The rewrite aims to improve clarity, structure, and readability while preserving the technical details.
In FormCMS, attributes with display types such as image, file, or gallery are represented as Asset Objects in GraphQL query results. These objects correspond to assets stored in the system's centralized Asset Library (see the Asset Library section for details). When querying entities with these attributes, the response includes structured asset data, such as the asset’s Path, Url, Name, Title, and other metadata.
Consider a course entity with an image field:
{
courseList {
id
name
image {
id
path
url
name
title
size
type
}
}
}
{
"data": {
"courseList": [
{
"id": 1,
"name": "Introduction to GraphQL",
"image": {
"id": "abc123",
"path": "2025-03-abc123",
"url": "/files/2025-03-abc123",
"name": "graphql_intro.jpg",
"title": "GraphQL Course Banner",
"size": 102400,
"type": "image/jpeg"
}
}
]
}
}
url field provides a fixed access point to the asset, ensuring reliable retrieval across the application.title can be used as captions or alt text for images, enhancing accessibility and SEO.image, file, gallery) with a uniform response format.When querying related entities in FormCMS, joining tables can result in duplicate records due to one-to-many relationships. The DISTINCT keyword helps eliminate these duplicates, but it has limitations that require careful query design.
DISTINCT?Consider the following data structure:
[{id: 1, title: "p1"}][{id: 1, name: "t1"}, {id: 2, name: "t2"}][{post_id: 1, tag_id: 1}, {post_id: 1, tag_id: 2}]A query joining these tables might look like this in SQL:
SELECT posts.id, posts.title
FROM posts
LEFT JOIN post_tag ON posts.id = post_tag.post_id
LEFT JOIN tags ON post_tag.tag_id = tags.id
WHERE tags.id > 0;
Without DISTINCT, the result duplicates the post for each tag:
[
{"id": 1, "title": "p1"}, // For tag_id: 1
{"id": 1, "title": "p1"} // For tag_id: 2
]
Using DISTINCT ensures each post appears only once:
SELECT DISTINCT posts.id, posts.title
FROM posts
LEFT JOIN post_tag ON posts.id = post_tag.post_id
LEFT JOIN tags ON post_tag.tag_id = tags.id
WHERE tags.id > 0;
Result:
[
{"id": 1, "title": "p1"}
]
DISTINCTIn some databases, such as SQL Server, DISTINCT cannot be applied to fields of type TEXT (or large data types like NTEXT or VARCHAR(MAX)). Including such fields in a query with DISTINCT causes errors.
To address this limitation, split the entity’s queries into two parts:
TEXT fields, using DISTINCT to avoid duplicates.{
postList {
id
title
}
}
TEXT fields, by querying a single record using its ID.{
post(idSet: 1) {
id
title
description # TEXT field
}
}
List query to avoid duplicates:
{
postList {
id
title
tags {
id
name
}
}
}
Detail query for a specific post:
{
post(idSet: 1) {
id
title
description # TEXT field, only queried here
tags {
id
name
}
}
}
DISTINCT.The page designer utilizes the open-source GrapesJS and Handlebars, enabling seamless binding of GrapesJS Components with FormCMS Queries for dynamic content rendering.
A landing page is typically the first page a visitor sees.
/page/<pagename>query.Example:
Landing Page
This page fetches data from:
A detail page provides specific information about an item.
/page/<pagename>/<router parameter>query.Example:
Course Detail Page
This page fetches data from:
https://fluent-cms-admin.azurewebsites.net/api/queries/course/one?course_id=22
The homepage is a special type of landing page named home.
/pages/home/, FormCMS renders /pages/home by default.Example:
The URL / will be resolved to /pages/home unless explicitly overridden.
Understanding the panels in GrapesJS is crucial for leveraging FormCMS's customization capabilities in the Page Designer UI. This section explains the purpose of each panel and highlights how FormCMS enhances specific areas to streamline content management and page design.

Style Manager:
Traits Panel:
Layers Panel:
Blocks Panel:
By familiarizing users with these panels and their integration points, this chapter ensures a smoother workflow and better utilization of FormCMS's advanced page-building tools.
FormCMS leverages Handlebars expressions for dynamic data binding in pages and components.
Singleton fields are enclosed within {{ }} to dynamically bind individual values.
Handlebars supports iterating over arrays using the {{#each}} block for repeating data structures.
{{#each course}}
<li>{{title}}</li>
{{/each}}
In FormCMS, you won’t explicitly see the {{#each}} statement in the Page Designer. If a block's data source is set to data-list, the system automatically generates the loop.
To bind a Data List to a component, follow these steps:
Data List component.| Field | Description |
|---|---|
| Query | The query to retrieve data. |
| Qs | Query string parameters to pass (e.g., ?status=featured, ?level=Advanced). |
| Offset | Number of records to skip. |
| Limit | Number of records to retrieve. |
| Pagination | Options for displaying content: |
| - Button: Divides content into multiple pages with navigation buttons (e.g., "Next," "Previous," or numbered buttons). | |
| - Infinite Scroll: Automatically loads more content as users scroll. Ideal for a single component at the bottom of the page. | |
| - None: Displays all available content at once without requiring additional user actions. |
You can copy a component to the clipboard on one page and paste it from the clipboard on another page.
Having established our understanding of FormCMS essentials like Entity, Query, and Page, we're ready to build a frontend for an online course website.
home): The main entry point, featuring sections like Featured Courses and Advanced Courses. Each course links to its respective Course Details page.course/{course_id}): Offers detailed information about a specific course and includes links to the Teacher Details page.teacher/{teacher_id}): Highlights the instructor’s profile and includes a section displaying their latest courses, which link back to the Course Details page. Home Page
|
|
+-------------------+
| |
v v
Latest Courses Course Details
| |
| |
v v
Course Details <-------> Teacher Details
Content-B component.course query./pages/course/{{id}}. The Handlebars expression {{id}} is dynamically replaced with the actual course ID during rendering.
course/{course_id} to capture the course_id parameter from the URL (e.g., /pages/course/20).{course_id:20} is passed to the course query, generating a WHERE id IN (20) clause to fetch the relevant course data./pages/teacher/{{teacher.id}}. Handlebars dynamically replaces {{teacher.id}} with the teacher’s ID. For example, if a teacher object has an ID of 3, the link renders as /pages/teacher/3.teacher/{teacher_id} to capture the teacher_id parameter from the URL.teacher query as the page’s data source.ECommerce A component onto the page.course query, filtered by the teacher’s ID (WHERE teacher IN (3)).
When rendering the page, the PageService automatically passes the teacher_id (e.g., {teacher_id: 3}) to the query.
To create a category entity in the Schema Builder, include parent and children attributes.
DataType: lookup & DisplayType: TreeSelect
Use this configuration to display a category as a property.
Edit Example
DataType: junction & DisplayType: Tree
Use this configuration to enable category-based navigation.
Edit Example
Tree Layer Menu:
Use the Tree Layer Menu component for hierarchical navigation.
Edit Example
Breadcrumbs:
Use the Breadcrumbs component to display navigation paths.
Edit Example
FormCMS saves each version of schemas, allowing users to roll back to earlier versions. Admins can freely test draft versions, while only published versions take effect.
To illustrate this feature, let's take a Page as an example. Once a page is published, it becomes publicly accessible. You may need version control for two main reasons:
After making changes, the latest version's status changes to draft in the Page List Page.
To manage versions, click the View History button to navigate to the History Version List Page.
Here, you can select any version and set it to published status.
Draft VersionTo preview a draft version, append sandbox=1 as a query parameter in the URL: Preview Draft Version Page.
Alternatively, click the View Page button on the Page Design page.
You can compare the difference between different versions, use the Schema Diff Tool.
You can duplicate any schema version and save it as a new schema.
The user engaagment feature enhances user engagement by enabling views, likes, saves, and shares. It also provides detailed analytics to help understand content performance.
GET /api/engagements/{entityName}/{recordId:long}
Increments the view count by 1. Returns the engagement status and count for: like, view, share, and save.
GET /api/engagements/record/{entityName}/{recordId}?type={view|share}
Retrieves engagement info of type view or share for a given entity record.
POST /api/engagements/toggle/{entityName}/{recordId}?type={like|save}&active={true|false}
Toggles the engagement (like or save) on or off based on the active flag.
The system cannot leverage traditional output caching due to dynamic nature of the content, which may lead to high database load under heavy traffic.
To address this, buffered writes are introduced. Engagement events are first stored in a buffer (in-memory or Redis), and then periodically flushed to the database, balancing performance and accuracy.
Below is a test script using k6 to simulate traffic and measure performance:
import http from 'k6/http';
import { check } from 'k6';
import { Trend } from 'k6/metrics';
const ResponseTime = new Trend('response_time', true);
export const options = {
stages: [
{ duration: '30s', target: 300 },
{ duration: '30s', target: 300 },
{ duration: '30s', target: 0 },
],
thresholds: {
'http_req_failed': ['rate<0.01'],
'http_req_duration': ['p(95)<500'],
'response_time': ['p(95)<500'],
},
};
export default function () {
const id = Math.floor(Math.random() * 100) + 1;
const res = http.get(`http://localhost:5000/api/engagements/post/${id}`);
ResponseTime.add(res.timings.duration);
check(res, { 'status is 200': (r) => r.status === 200 });
}
Each buffering strategy has its tradeoffs:
| Strategy | Performance | Scalability | Complexity | Avg Response Time |
|---|---|---|---|---|
| No Buffer | Medium | High | Low | ~35ms |
| Memory Buffer | High | Low | Low | ~4ms |
| Redis Buffer | High | High | Medium | ~12ms |
Choose the approach based on your system’s scalability requirements and infrastructure constraints.
Users can access their view history, liked posts, bookmarked posts, and manage authentication via GitHub OAuth in a personalized portal.
The User Portal in FormCMS provides a centralized interface for users to manage their social engagement, including viewing their interaction history, liked posts, bookmarked content, and authenticating seamlessly via GitHub OAuth. This enhances user engagement by offering a tailored experience to revisit, organize content, and simplify account creation.
Users can view a list of all items they have previously accessed, such as pages, posts, or other content. Each item in the history is displayed with a clickable link, allowing users to easily revisit the content.
The Liked Items section displays all posts or content that the user has liked. Users can browse their liked items, with options to unlike content or click through to view the full item, fostering seamless interaction with preferred content.
Users can organize and view their saved content in the Bookmarked Items section. Bookmarks can be grouped into custom folders for easy categorization, enabling users to efficiently manage and access their saved items by folder or as a complete list.
The User Portal supports GitHub OAuth for user authentication, streamlining the login and registration process. By integrating with GitHub's OAuth system, users can log in or register using their existing GitHub credentials, eliminating the need to create and manage a separate username and password for FormCMS.
Administrators can enable or configure GitHub OAuth in the Authentication Settings section, where they provide the GitHub OAuth client ID and secret, and define redirect URIs for seamless integration.
The User Portal displays items with the following metadata:
Metadata mappings are configured on the Entity Settings page, where administrators can define how data fields map to the portal's display. The following settings are available:
These settings allow for flexible customization, ensuring the User Portal displays content accurately and consistently across history, liked items, and bookmarked items.
The Popular Score is a dynamic metric that measures the engagement level of content record
The Popular Score is a simple way to measure how engaging content (like posts, videos, or articles) is based on user interactions such as views, likes, shares, and how long ago the content was posted. It helps platforms rank and promote content that’s getting attention, making it more visible on homepages, trending sections, or personalized feeds.
The Popular Score combines views, likes, shares, bookmarks, comments, and time since posting, with each part weighted to reflect its importance. Newer content gets a boost, while older content loses a bit of its score.
The Popular Score powers key features:
To handle lots of interactions without slowing down:
FormCMS's Comments Plugin enables adding a comments feature to any entity, enhancing user interaction.
Comments component from the Blocks toolbox onto your page. Customize its layout as needed.Layout Manager toolbox, select the Comment-form component and set its Entity Name trait.After configuring, click Save and Publish to enable the comments feature. The Comments Plugin is designed for Detail Pages, where comments are associated with an Entity Name and RecordId (automatically retrieved from the page URL parameters).
Authenticated users can add, edit, delete, like, and reply to comments. The Comments Plugin sends events for these actions, which are handled by other plugins. For example:
Each Detail Page is linked to a FormCMS GraphQL query. To include comments:
Comments field to your GraphQL query.FormCMS's Notification Plugin alerts users when their comments are liked or replied to, boosting engagement.
The Notification Bell displays the number of unread notifications for a user, enhancing their interaction with the platform. To add it:
Notification Bell block from the toolbox onto your page.When a user clicks the Notification Bell, they are directed to the Notification List page in the User Portal. From there, users can:
A website can integrate a subscription feature to generate revenue.
FormCms integrates Stripe to ensure secure payments. FormCms does not store any credit card information; it only uses the Stripe subscription ID to query subscription status. Admins and users can visit the Stripe website to view transactions and logs.
Follow the Stripe documentation to obtain the Stripe Publishable Key and Secret Key.
Add these keys to the appSettings.json file as follows:
"Stripe": {
"SecretKey": "sk_***",
"PublishableKey": "pk_***"
}
For the online course demo system at https://fluent-cms-admin.azurewebsites.net/, each course may include multiple lessons. The course video serves as an introduction, and the first lesson of a course is free. When users attempt to access further lessons, they are restricted and prompted by FormCms to subscribe.
To implement this, add an accessLevel field to the lesson entity.
Then, include a condition accessLevel:{lte: $access_level} in the query to provide data for the Lesson Page:
query lesson($lesson_id:Int, $access_level:Int){
lesson(idSet:[$lesson_id],
accessLevel:{lte: $access_level}
){
id, name, description, introduction, accessLevel
}
When an unpaid user attempts to access restricted content (requiring a subscription), FormCms redirects them to the Stripe website for payment. After payment, users can view their subscription status in the user portal.
FormCMS's Full-Text Search feature allows users to search for keywords in titles, content, and other fields.
Search Bar component from the Blocks toolbox onto your page.Search page, drag a component from the Data List catalog, and bind its query to the search query.FormCMS searches for the query keyword in the title, subtitle, and content fields, with keywords in the title receiving the highest score.
FormCMS employs advanced caching strategies to boost performance.
For detailed information on ASP.NET Core caching, visit the official documentation: ASP.NET Core Caching Overview.
FormCMS automatically invalidates schema caches whenever schema changes are made. The schema cache consists of two types:
Entity Schema Cache
Caches all entity definitions required to dynamically generate GraphQL types.
Query Schema Cache
Caches query definitions, including dependencies on multiple related entities, to compose efficient SQL queries.
By default, schema caching is implemented using IMemoryCache. However, you can override this by providing a HybridCache. Below is a comparison of the two options:
To implement a HybridCache, use the following code:
builder.AddRedisDistributedCache(connectionName: CmsConstants.Redis);
builder.Services.AddHybridCache();
FormCMS does not automatically invalidate data caches. Instead, it leverages ASP.NET Core's output caching for a straightforward implementation. Data caching consists of two types:
Query Data Cache
Caches the results of queries for faster access.
Page Cache
Caches the output of rendered pages for quick delivery.
By default, output caching is disabled in FormCMS. To enable it, configure and inject the output cache as shown below:
builder.Services.AddOutputCache(cacheOption =>
{
cacheOption.AddBasePolicy(policyBuilder => policyBuilder.Expire(TimeSpan.FromMinutes(1)));
cacheOption.AddPolicy(CmsOptions.DefaultPageCachePolicyName,
b => b.Expire(TimeSpan.FromMinutes(2)));
cacheOption.AddPolicy(CmsOptions.DefaultQueryCachePolicyName,
b => b.Expire(TimeSpan.FromSeconds(1)));
});
// After builder.Build();
app.UseOutputCache();
FormCMS leverages Aspire to simplify deployment.
A scalable deployment of FormCMS involves multiple web application nodes, a Redis server for distributed caching, and one or more database servers, all behind a load balancer.
+------------------+
| Load Balancer |
+------------------+
|
+-----------------+-----------------+
| |
+------------------+ +------------------+
| Web App 1 | | Web App 2 |
| +-----------+ | | +-----------+ |
| | Local Cache| | | | Local Cache| |
+------------------+ +------------------+
| |
| |
+-----------------+-----------------+
| |
+------------------+ +------------------+
| Database Server | | Redis Server |
+------------------+ +------------------+
For high-traffic applications, FormCMS supports horizontal database sharding for engagement, comments, notifications, and full-text search features. See the Database Sharding section for detailed configuration.
+------------------+
| Load Balancer |
+------------------+
|
+-----------------+-----------------+
| |
+------------------+ +------------------+
| Web App Node | | Web App Node |
+------------------+ +------------------+
| |
+-------------------+---------------+
|
+-------------------+---+--------+--------------+
| | | |
+----v-----+ +------v---+ +---v------+ +---v-----+
| CMS DB | |Engagement| | Comment | | Notify |
|(Primary) | | Shards | | Shards | | Shards |
+----------+ +----------+ +----------+ +---------+
Example Web project on GitHub
Example Aspire project on GitHub
To emulate the production environment locally, FormCMS leverages Aspire. Here's an example setup:
var builder = DistributedApplication.CreateBuilder(args);
// Adding Redis and PostgreSQL services
var redis = builder.AddRedis(name: CmsConstants.Redis);
var db = builder.AddPostgres(CmsConstants.Postgres);
// Configuring the web project with replicas and references
builder.AddProject<Projects.FormCMS_Course>(name: "web")
.WithEnvironment(CmsConstants.DatabaseProvider, CmsConstants.Postgres)
.WithReference(redis)
.WithReference(db)
.WithReplicas(2);
builder.Build().Run();
builder.Configuration.GetValue<string>();
builder.Configuration.GetConnectionString();
By adopting these caching and deployment strategies, FormCMS ensures improved performance, scalability, and ease of configuration.
Optimizing query performance by syncing relational data to a document database, such as MongoDB, significantly improves speed and scalability for high-demand applications.
ASP.NET Core's output caching reduces database access when repeated queries are performed. However, its effectiveness is limited when dealing with numerous distinct queries:
For the query below, FormCMS joins the post, tag, category, and author tables:
query post_sync($id: Int) {
postList(idSet: [$id], sort: id) {
id, title, body, abstract
tag {
id, name
}
category {
id, name
}
author {
id, name
}
}
}
By saving each post along with its related data as a single document in a document database, such as MongoDB, several improvements are achieved:
Using K6 scripts with 1,000 virtual users concurrently accessing the query API, the performance difference between PostgreSQL and MongoDB was tested, showing MongoDB to be significantly faster:
export default function () {
const id = Math.floor(Math.random() * 1000000) + 1; // Random id between 1 and 1,000,000
/* PostgreSQL */
// const url = `http://localhost:5091/api/queries/post_sync/?id=${id}`;
/* MongoDB */
const url = `http://localhost:5091/api/queries/post/?id=${id}`;
const res = http.get(url);
check(res, {
'is status 200': (r) => r.status === 200,
'response time is < 200ms': (r) => r.timings.duration < 200,
});
}
/*
MongoDB:
http_req_waiting...............: avg=50.8ms min=774µs med=24.01ms max=3.23s p(90)=125.65ms p(95)=211.32ms
PostgreSQL:
http_req_waiting...............: avg=5.54s min=11.61ms med=4.08s max=44.67s p(90)=13.45s p(95)=16.53s
*/

To enable publishing messages to the Message Broker, use Aspire to add a NATS resource. Detailed documentation is available in Microsoft Docs.
Add the following line to the Aspire HostApp project:
builder.AddNatsClient(AppConstants.Nats);
Add the following lines to the WebApp project:
builder.AddNatsClient(AppConstants.Nats);
var entities = builder.Configuration.GetRequiredSection("TrackingEntities").Get<string[]>()!;
builder.Services.AddNatsMessageProducer(entities);
FormCMS publishes events for changes made to entities listed in appsettings.json:
{
"TrackingEntities": [
"post"
]
}
Add the following to the Worker App:
var builder = Host.CreateApplicationBuilder(args);
builder.AddNatsClient(AppConstants.Nats);
builder.AddMongoDBClient(AppConstants.MongoCms);
var apiLinksArray = builder.Configuration.GetRequiredSection("ApiLinksArray").Get<ApiLinks[]>()!;
builder.Services.AddNatsMongoLink(apiLinksArray);
Define the ApiLinksArray in appsettings.json to specify entity changes and the corresponding query API:
{
"ApiLinksArray": [
{
"Entity": "post",
"Api": "http://localhost:5001/api/queries/post_sync",
"Collection": "post",
"PrimaryKey": "id"
}
]
}
When changes occur to the post entity, the Worker Service calls the query API to retrieve aggregated data and saves it as a document.
After adding a new entry to ApiLinksArray, the Worker App will perform a migration from the start to populate the Document DB.

To enable MongoDB queries in your WebApp, use the Aspire MongoDB integration. Details are available in Microsoft Docs.
Add the following code to your WebApp:
builder.AddMongoDBClient(connectionName: AppConstants.MongoCms);
var queryLinksArray = builder.Configuration.GetRequiredSection("QueryLinksArray").Get<QueryLinks[]>()!;
builder.Services.AddMongoDbQuery(queryLinksArray);
Define QueryLinksArray in appsettings.json to specify MongoDB queries:
{
"QueryLinksArray": [
{ "Query": "post", "Collection": "post" },
{ "Query": "post_test_mongo", "Collection": "post" }
]
}
The WebApp will now query MongoDB directly for the specified collections.
Follow these steps to integrate FormCMS into your project using a NuGet package.
You can reference the code from https://github.com/FormCMS/FormCMS/tree/main/examples
Create a New ASP.NET Core Web Application.
Add the NuGet Package: To add FormCMS, run the following command:
dotnet add package FormCMS
Modify Program.cs:
Add the following line before builder.Build() to configure the database connection (use your actual connection string):
builder.AddSqliteCms("Data Source=cms.db");
var app = builder.Build();
Currently, FormCMS supports AddSqliteCms, AddSqlServerCms, and AddPostgresCms.
Initialize FormCMS:
Add this line after builder.Build() to initialize the CMS:
await app.UseCmsAsync();
This will bootstrap the router and initialize the FormCMS schema table.
Learn how to customize your application by adding validation logic, hook functions, and producing events to Kafka.
You can define simple C# expressions in the Validation Rule of attributes using Dynamic Expresso. For example, a rule like name != null ensures the name attribute is not null.
Additionally, you can specify a Validation Error Message to provide users with feedback when validation fails.
Dynamic Expresso supports regular expressions, allowing you to write rules like Regex.IsMatch(email, "^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$").
Note: Since
Dynamic Expressodoesn't support verbatim strings, you must escape backslashes (\).
To implement custom business logic, such as verifying that a teacher entity has valid email and phone details, you can register hook functions to run before adding or updating records:
var registry = app.GetHookRegistry();
// Hook function for pre-add validation
registry.EntityPreAdd.Register("teacher", args =>
{
VerifyTeacher(args.RefRecord);
return args;
});
// Hook function for pre-update validation
registry.EntityPreUpdate.Register("teacher", args =>
{
VerifyTeacher(args.RefRecord);
return args;
});
To enable asynchronous business logic through an event broker like Kafka, you can produce events using hook functions. This feature requires just a few additional setup steps:
Add the Kafka producer configuration:
builder.AddKafkaMessageProducer("localhost:9092");
Register the message producer hook:
app.RegisterMessageProducerHook();
Here’s a complete example:
builder.AddSqliteCms("Data Source=cmsapp.db").PrintVersion();
builder.AddKafkaMessageProducer("localhost:9092");
var app = builder.Build();
await app.UseCmsAsync(false);
app.RegisterMessageProducerHook();
With this setup, events are produced to Kafka, allowing consumers to process business logic asynchronously.
FormCMS supports horizontal database sharding to scale high-volume features independently.
FormCMS allows you to distribute data across multiple database servers for improved scalability and performance. While the main CMS content remains in a single database (optimized for CDN caching), high-volume features like engagements, comments, notifications, and full-text search can be scaled independently using dedicated databases or sharding.
+------------------+ +------------------+ +------------------+
| Web App Node | | Web App Node | | Web App Node |
+------------------+ +------------------+ +------------------+
| | |
+------------------------+------------------------+
|
+------------------------+------------------------+
| | | | |
+--------v----+ +----v-----+ +--v-------+ +-v--------+ +-v------+
| CMS | |Engagement| | Comment | | Notify | | FTS |
| Database | | Shards | | Shards | | Shards | | DB |
| (Primary) | | (2-N) | | (2-N) | | (2-N) | | |
+-------------+ +----------+ +----------+ +----------+ +--------+
FormCMS supports sharding for the following features:
userId (consistent hashing)recordId (entity ID)userIdAll sharding configuration is done in appsettings.json:
{
"EngagementShards": [
{
"DatabaseProvider": "Postgres",
"LeadConnStr": "Host=db1.example.com;Database=engagement1;Username=user;Password=pass",
"FollowConnStrings": [
"Host=db1-replica.example.com;Database=engagement1;Username=user;Password=pass"
],
"Start": 0,
"End": 6
},
{
"DatabaseProvider": "Postgres",
"LeadConnStr": "Host=db2.example.com;Database=engagement2;Username=user;Password=pass",
"FollowConnStrings": [],
"Start": 6,
"End": 12
}
]
}
{
"CommentShards": [
{
"DatabaseProvider": "Postgres",
"LeadConnStr": "Host=db3.example.com;Database=comment1;Username=user;Password=pass",
"FollowConnStrings": [],
"Start": 0,
"End": 4
},
{
"DatabaseProvider": "Postgres",
"LeadConnStr": "Host=db4.example.com;Database=comment2;Username=user;Password=pass",
"FollowConnStrings": [],
"Start": 4,
"End": 8
},
{
"DatabaseProvider": "Postgres",
"LeadConnStr": "Host=db5.example.com;Database=comment3;Username=user;Password=pass",
"FollowConnStrings": [],
"Start": 8,
"End": 12
}
]
}
{
"NotifyShards": [
{
"DatabaseProvider": "Postgres",
"LeadConnStr": "Host=db6.example.com;Database=notify1;Username=user;Password=pass",
"FollowConnStrings": [],
"Start": 0,
"End": 3
},
{
"DatabaseProvider": "Postgres",
"LeadConnStr": "Host=db7.example.com;Database=notify2;Username=user;Password=pass",
"FollowConnStrings": [],
"Start": 3,
"End": 6
},
{
"DatabaseProvider": "Postgres",
"LeadConnStr": "Host=db8.example.com;Database=notify3;Username=user;Password=pass",
"FollowConnStrings": [],
"Start": 6,
"End": 9
},
{
"DatabaseProvider": "Postgres",
"LeadConnStr": "Host=db9.example.com;Database=notify4;Username=user;Password=pass",
"FollowConnStrings": [],
"Start": 9,
"End": 12
}
]
}
{
"FtsProvider": "Postgres",
"FtsPrimaryConnString": "Host=fts-primary.example.com;Database=fts;Username=user;Password=pass",
"FtsReplicaConnStrings": [
"Host=fts-replica1.example.com;Database=fts;Username=user;Password=pass",
"Host=fts-replica2.example.com;Database=fts;Username=user;Password=pass"
]
}
Configure sharding in your Program.cs:
void AddCmsFeatures()
{
// Enable engagement with sharding
var enableEngagementBuffer = builder.Configuration.GetValue<bool>("EnableEngagementBuffer");
var engagementShards = builder.Configuration.GetSection("EngagementShards").Get<ShardConfig[]>();
builder.Services.AddEngagement(enableEngagementBuffer, engagementShards);
// Enable comments with sharding
var commentShards = builder.Configuration.GetSection("CommentShards").Get<ShardConfig[]>();
builder.Services.AddComments(commentShards);
// Enable notifications with sharding
var notifyShards = builder.Configuration.GetSection("NotifyShards").Get<ShardConfig[]>();
builder.Services.AddNotify(notifyShards);
// Configure full-text search
var ftsProvider = builder.Configuration.GetValue<string>("FtsProvider") ?? dbProvider;
var ftsPrimaryConnString = builder.Configuration.GetValue<string>("FtsPrimaryConnString") ?? dbConnStr;
var ftsReplicaConnStrings = builder.Configuration.GetSection("FtsReplicaConnStrings").Get<string[]>();
builder.Services.AddSearch(Enum.Parse<FtsProvider>(ftsProvider), ftsPrimaryConnString, ftsReplicaConnStrings);
}
Each shard configuration supports:
Postgres, Mysql, SqlServer, Sqlite)FormCMS uses MD5 hashing for consistent shard routing:
hash = MD5(shardKey).GetHashCode() % 12
Example with 3 shards:
Start=0, End=4 → handles hashes 0,1,2,3Start=4, End=8 → handles hashes 4,5,6,7Start=8, End=12 → handles hashes 8,9,10,11Each shard can have multiple read replicas specified in FollowConnStrings:
{
"LeadConnStr": "Host=primary.example.com;Database=engagement1;...",
"FollowConnStrings": [
"Host=replica1.example.com;Database=engagement1;...",
"Host=replica2.example.com;Database=engagement1;..."
]
}
Benefits:
LeadConnStr (primary)// No sharding - uses main CMS database
builder.Services.AddEngagement(enableBuffering: true);
builder.Services.AddComments();
builder.Services.AddNotify();
Low-Medium Traffic (< 100K users):
Medium-High Traffic (100K - 1M users):
High Traffic (1M+ users):
Based on production testing with 100M engagement records:
Without Sharding:
With 2-4 Shards + Buffering:
MaxPoolSize based on concurrent requests⚠️ Important: Changing shard configuration requires data migration. The hash function determines routing, so modifying Start/End ranges will misroute existing data.
For resharding:
The backend is written in ASP.NET Core, the Admin Panel uses React, and the Schema Builder is developed with jQuery.
The system comprises three main components:

The backend is influenced by Domain-Driven Design (DDD).

Code organization follows this diagram:

The Core layer encapsulates:
Entity, Filter, Sort, and similar components for building queries.Hook Registry, enabling developers to integrate custom plugins.Note: The Core layer is independent of both the Application and Infrastructure layers.
The Application layer provides the following functionalities:
Includes
Buildersto configure Dependency Injection and manage Infrastructure components.
The Infrastructure layer defines reusable system infrastructural components.
A separate Util component contains static classes with pure functions.

This chapter describes the systems' automated testing strategy
Favors integration testing over unit testing because integration tests can catch more real-world issues. For example, when inserting a record into the database, multiple modules are involved:
EntitiesHandlerEntitiesServiceEntity (in core)Query executors (e.g., SqlLite, Postgres, SqlServer)Writing unit tests for each function and mocking its upstream and downstream services can be tedious. Instead, FormCMS focuses on checking the input and output of RESTful API endpoints in its integration tests.
/formcms/server/FormCMS.Course.TestsThis project focuses on verifying the functionalities of the FormCMS.Course example project.
/formcms/server/FormCMS.App.TestsThis project is dedicated to testing experimental functionalities, like MongoDB and Kafka plugins.
FAQs
A headless CMS with GraphQL API and drag-and-drop page designer.
We found that formcms demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 1 open source maintainer collaborating on the project.
Did you know?

Socket for GitHub automatically highlights issues in each pull request and monitors the health of all your open source dependencies. Discover the contents of your packages and block harmful activity before you install or update your dependencies.

Security News
GitHub postponed a new billing model for self-hosted Actions after developer pushback, but moved forward with hosted runner price cuts on January 1.

Research
Destructive malware is rising across open source registries, using delays and kill switches to wipe code, break builds, and disrupt CI/CD.

Security News
Socket CTO Ahmad Nassri shares practical AI coding techniques, tools, and team workflows, plus what still feels noisy and why shipping remains human-led.