Content Operations

Content Operations

Read, write, and organize content through the Go SDK -- CRUD, content trees, publishing, batch updates, querying, and content delivery.

CRUD via Generic Resources

Most content resources on the client are instances of Resource[Entity, CreateParams, UpdateParams, ID]. This generic type provides a consistent set of methods:

Method Signature Description
List (ctx) ([]E, error) Fetch all items
Get (ctx, id) (*E, error) Fetch one item by ID
Create (ctx, params) (*E, error) Create a new item
Update (ctx, params) (*E, error) Update an existing item
Delete (ctx, id) error Delete an item by ID
ListPaginated (ctx, PaginationParams) (*PaginatedResponse[E], error) Paginated listing
Count (ctx) (int64, error) Total count without fetching data
RawList (ctx, url.Values) (json.RawMessage, error) Raw listing with arbitrary query params

Creating Content Data

parentID := modula.ContentID("01HX...")
datatypeID := modula.DatatypeID("01HX...")

item, err := client.ContentData.Create(ctx, modula.CreateContentDataParams{
    ParentID:   &parentID,
    DatatypeID: &datatypeID,
    Status:     modula.ContentStatusDraft,
})
if err != nil {
    log.Fatal(err)
}
fmt.Printf("Created content: %s\n", item.ContentDataID)

Setting Field Values

After creating a content data node, set its field values:

field, err := client.ContentFields.Create(ctx, modula.CreateContentFieldParams{
    ContentDataID: item.ContentDataID,
    FieldID:       titleFieldID,
    Value:         "Hello World",
})
if err != nil {
    log.Fatal(err)
}

Updating Content

updated, err := client.ContentData.Update(ctx, modula.UpdateContentDataParams{
    ContentDataID: item.ContentDataID,
    Status:        modula.ContentStatusDraft,
})

Deleting Content

err := client.ContentData.Delete(ctx, contentID)
if modula.IsNotFound(err) {
    // Already deleted or never existed.
}

Composite Operations

For operations that span multiple tables atomically, use the composite resources:

// Create content with fields in a single request.
resp, err := client.ContentComposite.CreateWithFields(ctx, modula.ContentCreateParams{
    DatatypeID: datatypeID,
    ParentID:   &parentID,
    Fields: map[string]string{
        "title": "New Post",
        "body":  "Content here...",
    },
})

// Delete content and all its descendants recursively.
delResp, err := client.ContentComposite.DeleteRecursive(ctx, contentID)
fmt.Printf("Deleted %d nodes\n", delResp.Deleted)

Content Trees

Content in ModulaCMS is organized as trees. Each route has its own content tree, and global trees provide site-wide content like navigation and footers without a route.

Getting a Tree by Route

nodes, err := client.ContentTree.GetByRoute(ctx, routeID)
if err != nil {
    log.Fatal(err)
}
for _, node := range nodes {
    fmt.Printf("%s: %s (%s)\n", node.ContentID, node.Title, node.Status)
}

Each ContentTreeNode contains the content ID, datatype ID, route ID, title, slug, status, and timestamps.

Bulk Tree Operations with Save

The ContentTree.Save method accepts creates, updates, and deletes in a single atomic request. Use TreeNodeCreate for new nodes and TreeNodeUpdate for pointer changes:

resp, err := client.ContentTree.Save(ctx, modula.TreeSaveRequest{
    ContentID: rootContentID,
    Creates: []modula.TreeNodeCreate{
        {
            ClientID:   "temp-1",
            DatatypeID: string(cardDatatypeID),
            ParentID:   modula.StringPtr(string(cardsContainerID)),
        },
    },
    Updates: []modula.TreeNodeUpdate{
        {
            ContentDataID: existingNodeID,
            ParentID:      modula.StringPtr(string(newParentID)),
        },
    },
    Deletes: []modula.ContentID{obsoleteNodeID},
})
if err != nil {
    log.Fatal(err)
}
fmt.Printf("Created: %d, Updated: %d, Deleted: %d\n",
    resp.Created, resp.Updated, resp.Deleted)

// resp.IDMap maps client-generated temp IDs to server-assigned IDs.
realID := resp.IDMap["temp-1"]

The ClientID field on TreeNodeCreate is a temporary identifier you assign. After the save, resp.IDMap maps each ClientID to the server-assigned ContentID. Use modula.StringPtr() to create *string values for pointer fields.

Reordering and Moving Nodes

Reorder children within a parent:

resp, err := client.ContentReorder.Reorder(ctx, modula.ContentReorderRequest{
    ParentID:   &parentID,
    OrderedIDs: []modula.ContentID{thirdID, firstID, secondID},
})

Move a node to a different parent at a specific position:

resp, err := client.ContentReorder.Move(ctx, modula.ContentMoveRequest{
    NodeID:      nodeID,
    NewParentID: &targetParentID,
    Position:    0, // Insert as first child
})

Publishing

The Publishing resource manages the content lifecycle: draft, published, and scheduled.

Good to know: The client exposes two publishing resources -- Publishing for public content and AdminPublishing for admin content -- with identical methods.

Publish

Transition content from draft to published, creating a version snapshot:

resp, err := client.Publishing.Publish(ctx, modula.PublishRequest{
    ContentDataID: contentID,
})
if err != nil {
    log.Fatal(err)
}
fmt.Printf("Published at %s, version %s\n", resp.PublishedAt, resp.VersionID)

Unpublish

Revert published content back to draft:

resp, err := client.Publishing.Unpublish(ctx, modula.PublishRequest{
    ContentDataID: contentID,
})

Schedule

Set content to publish at a future time:

resp, err := client.Publishing.Schedule(ctx, modula.ScheduleRequest{
    ContentDataID: contentID,
    PublishAt:     "2026-04-01T09:00:00Z",
})

Version History

List all version snapshots for a content item:

versions, err := client.Publishing.ListVersions(ctx, string(contentID))
for _, v := range versions {
    fmt.Printf("Version %s: created %s\n", v.ContentVersionID, v.DateCreated)
}

Get a specific version:

version, err := client.Publishing.GetVersion(ctx, string(versionID))

Restore a Version

Replace the current draft with a previous version's field values:

resp, err := client.Publishing.Restore(ctx, modula.RestoreRequest{
    ContentDataID: contentID,
    VersionID:     versionID,
})

Batch Updates

Send multiple content updates in a single request:

raw, err := client.ContentBatch.Update(ctx, batchPayload)

Structure the batch payload based on your update needs. The SDK returns the response as json.RawMessage for flexibility.

Content Delivery

The Content resource provides the public-facing endpoint for fetching rendered pages by slug. This is the primary read path for frontend applications:

raw, err := client.Content.GetPage(ctx, "/about", "clean", "en-US")
if err != nil {
    log.Fatal(err)
}
// raw is json.RawMessage -- unmarshal into your application-specific type.

Parameters:

  • slug -- The URL path of the content page (e.g., "/about", "/blog/my-post").
  • format -- Response format: "contentful", "sanity", "strapi", "wordpress", "clean", or "raw". Each formats the tree differently for compatibility with various frontend frameworks.
  • locale -- Locale code (e.g., "en-US", "fr-FR"). Empty string uses the default locale.

Querying Content

The Query resource provides filtered, sorted, paginated content queries by datatype name. This is the primary way to build collection pages (blog listing, product catalog, etc.):

result, err := client.Query.Query(ctx, "blog-posts", &modula.QueryParams{
    Sort:   "-date_created",
    Limit:  10,
    Offset: 0,
    Status: "published",
    Locale: "en-US",
    Filters: map[string]string{
        "category": "tutorials",
    },
})
if err != nil {
    log.Fatal(err)
}

fmt.Printf("Showing %d of %d %s items\n",
    len(result.Data), result.Total, result.Datatype.Label)

for _, item := range result.Data {
    fmt.Printf("  %s: %s\n", item.ContentDataID, item.Fields["title"])
}

QueryParams Fields

Field Type Description
Sort string Sort field. Prefix with - for descending (e.g., "-date_created").
Limit int Maximum items to return. Server default when 0.
Offset int Items to skip for pagination.
Locale string Locale code filter. Empty uses default locale.
Status string Filter by content status ("published", "draft").
Filters map[string]string Field-level filters. Keys are field names, values are matched exactly.

Use Total from the response for building pagination controls. The response also includes Datatype metadata (name and label) for the queried type.

Content Healing

Detect and repair structural inconsistencies in content trees:

// Dry run first to see what would change.
report, err := client.ContentHeal.Heal(ctx, true)
fmt.Printf("Scanned %d nodes, found %d repairs needed\n",
    report.ContentDataScanned, len(report.ContentDataRepairs))

// Apply repairs.
report, err = client.ContentHeal.Heal(ctx, false)

Next Steps