In the third part of this multi-part series, we present Radixia Maca, our open-source solution for AI-composable commerce.

As digital commerce evolves, content becomes more than just a supporting layer—it becomes a strategic asset. In Part 3 of our series on Radixia Maca, our open-source AI-composable commerce stack, we shift our focus to building a robust headless content layer using Strapi CMS. This chapter is about structure, governance, and seamless integration. Strapi empowers us to manage rich product data, editorial content, and media assets in a centralized, API-first manner, making it easy to decouple content from frontend delivery. Whether we’re creating SKUs for ServerlessDay T-shirts or syncing catalog updates to external systems, Strapi serves as the authoritative source of content truth. Combined with AWS Fargate for serverless deployment and a custom plugin for real-time integration with Commerce Layer, this setup forms the backbone of content operations in Radixia Maca.

Strapi Service Structure

Our Strapi service is configured to run on AWS Fargate, a serverless container service that allows us to run containers without managing servers. We've already defined the necessary infrastructure in StrapiServiceConstruct our CDK project. Now let's look at how to configure Strapi for our ServerlessDay merchandising use case.

Dockerfile and Docker Compose

Let's start with the Dockerfile that defines the Docker image for Strapi:dockerfile

# Dockerfile for Strapi CMS
FROM node:18-alpine

# Install necessary dependencies
RUN apk add --no-cache build-base gcc autoconf automake zlib-dev libpng-dev vips-dev > /dev/null 2>&1

# Create application directory
WORKDIR /opt/app

# Copy package.json and package-lock.json
COPY ./package.json ./
COPY ./package-lock.json ./

# Install dependencies
RUN npm install

# Copy the rest of the application
COPY . .

# Build the application
RUN npm run build

# Expose port 1337
EXPOSE 1337

# Start command
CMD ["npm", "run", "start"]

For local development, we use Docker Compose to configure Strapi along with a PostgreSQL database:yaml

# docker-compose.yml for Strapi CMS
version: '3'

services:
  strapi:
    build: .
    container_name: strapi-cms
    restart: unless-stopped
    env_file: .env
    environment:
      DATABASE_CLIENT: postgres
      DATABASE_HOST: postgres
      DATABASE_PORT: 5432
      DATABASE_NAME: strapi
      DATABASE_USERNAME: strapi
      DATABASE_PASSWORD: ${DATABASE_PASSWORD}
      NODE_ENV: production
      SYNC_LAMBDA_URL: ${SYNC_LAMBDA_URL}
    volumes:
      - ./app:/opt/app
      - ./config:/opt/app/config
      - ./src:/opt/app/src
      - ./package.json:/opt/package.json
      - ./package-lock.json:/opt/package-lock.json
    ports:
      - "1337:1337"
    networks:
      - strapi-network
    depends_on:
      - postgres

  postgres:
    image: postgres:14-alpine
    container_name: postgres
    restart: unless-stopped
    env_file: .env
    environment:
      POSTGRES_USER: strapi
      POSTGRES_PASSWORD: ${DATABASE_PASSWORD}
      POSTGRES_DB: strapi
    volumes:
      - postgres-data:/var/lib/postgresql/data
    ports:
      - "5432:5432"
    networks:
      - strapi-network

networks:
  strapi-network:
    driver: bridge

volumes:
  postgres-data:

Environment Configuration

Let's create an .env.example file that will serve as a template for the actual .env file:

# Configuration file .env for Strapi CMS
# This file contains environment variables for the Strapi service

# Database
DATABASE_PASSWORD=strapi_password_example

# Lambda Integration
SYNC_LAMBDA_URL=https://example.execute-api.us-east-1.amazonaws.com/prod/sync

# Strapi Configuration
ADMIN_JWT_SECRET=your-admin-jwt-secret
JWT_SECRET=your-jwt-secret
API_TOKEN_SALT=your-api-token-salt

# AWS Configuration
AWS_REGION=us-east-1
AWS_ACCESS_KEY_ID=your-access-key
AWS_SECRET_ACCESS_KEY=your-secret-key

# Commerce Layer Configuration
COMMERCE_LAYER_CLIENT_ID=your-client-id
COMMERCE_LAYER_CLIENT_SECRET=your-client-secret
COMMERCE_LAYER_ENDPOINT=https://yourdomain.commercelayer.io

Content Models

Now, let's define the content models for our e-commerce platform. Let's start with the model for products:

// src/api/product/content-types/product/schema.json
module.exports = {
  kind: 'collectionType',
  collectionName: 'products',
  info: {
    singularName: 'product',
    pluralName: 'products',
    displayName: 'Product',
    description: 'ServerlessDay merchandising products',
  },
  options: {
    draftAndPublish: true,
  },
  attributes: {
    name: {
      type: 'string',
      required: true,
      unique: true,
    },
    description: {
      type: 'text',
      required: true,
    },
    sku: {
      type: 'string',
      required: true,
      unique: true,
    },
    price: {
      type: 'decimal',
      required: true,
      default: 0,
    },
    stock: {
      type: 'integer',
      default: 0,
    },
    colors: {
      type: 'string',
    },
    sizes: {
      type: 'string',
    },
    image: {
      type: 'media',
      allowedTypes: ['images'],
      multiple: false,
    },
    category: {
      type: 'relation',
      relation: 'manyToOne',
      target: 'api::category.category',
      inversedBy: 'products',
    },
    commerceLayerId: {
      type: 'string',
    },
    commerceLayerSyncedAt: {
      type: 'datetime',
    },
  },
};

Let's also define the model for categories:

// src/api/category/content-types/category/schema.json
module.exports = {
  kind: 'collectionType',
  collectionName: 'categories',
  info: {
    singularName: 'category',
    pluralName: 'categories',
    displayName: 'Category',
    description: 'Product categories for merchandising',
  },
  options: {
    draftAndPublish: true,
  },
  attributes: {
    name: {
      type: 'string',
      required: true,
      unique: true,
    },
    description: {
      type: 'text',
    },
    products: {
      type: 'relation',
      relation: 'oneToMany',
      target: 'api::product.product',
      mappedBy: 'category',
    },
    image: {
      type: 'media',
      allowedTypes: ['images'],
      multiple: false,
    },
    commerceLayerId: {
      type: 'string',
    },
  },
};

And finally, the model for orders:

// src/api/order/content-types/order/schema.json
module.exports = {
  kind: 'collectionType',
  collectionName: 'orders',
  info: {
    singularName: 'order',
    pluralName: 'orders',
    displayName: 'Order',
    description: 'Merchandising orders',
  },
  options: {
    draftAndPublish: false,
  },
  attributes: {
    commerceLayerId: {
      type: 'string',
      required: true,
      unique: true,
    },
    number: {
      type: 'string',
      required: true,
    },
    status: {
      type: 'enumeration',
      enum: [
        'pending',
        'approved',
        'fulfilled',
        'shipped',
        'delivered',
        'canceled'
      ],
      default: 'pending',
    },
    customerEmail: {
      type: 'email',
      required: true,
    },
    totalAmount: {
      type: 'decimal',
      required: true,
    },
    currency: {
      type: 'string',
      required: true,
    },
    items: {
      type: 'json',
    },
    shippingAddress: {
      type: 'json',
    },
    billingAddress: {
      type: 'json',
    },
    metadata: {
      type: 'json',
    },
  },
};

Plugin for Commerce Layer Integration

Let's create a custom plugin to integrate Strapi with Commerce Layer. This plugin will handle data synchronization between the two systems. First, let's configure the plugin in the config/plugins.js file:

// config/plugins.js
module.exports = {
  // Commerce Layer Plugin for Strapi
  'commerce-layer-integration': {
    enabled: true,
    resolve: './src/plugins/commerce-layer-integration',
    config: {
      syncEndpoint: process.env.SYNC_LAMBDA_URL,
      commerceLayerEndpoint: process.env.COMMERCE_LAYER_ENDPOINT,
      clientId: process.env.COMMERCE_LAYER_CLIENT_ID,
      clientSecret: process.env.COMMERCE_LAYER_CLIENT_SECRET,
      // Webhook configuration
      webhooks: {
        enabled: true,
        events: [
          'product.created',
          'product.updated',
          'product.deleted'
        ]
      },
      // Field mapping between Strapi and Commerce Layer
      fieldMapping: {
        products: {
          name: 'name',
          description: 'description',
          sku: 'sku_code',
          price: 'amount_cents',
          stock: 'quantity',
          category: 'metadata.category',
          image: 'image_url'
        }
      }
    }
  }
};

Now let's implement the plugin itself:

// src/plugins/commerce-layer-integration/index.js
'use strict';

module.exports = {
  /**
   * Plugin for Commerce Layer integration
   */
  register({ strapi })  {
    // Register a hook to send updates to Commerce Layer when a product is modified
    strapi.hooks.addHook('afterUpdate.product', async (result) => {
      try {
        // Get the sync Lambda URL from the environment
        const syncLambdaUrl = process.env.SYNC_LAMBDA_URL;
        
        if (!syncLambdaUrl) {
          strapi.log.error('SYNC_LAMBDA_URL not configured in environment');
          return;
        }
        
        // Send a request to the sync Lambda
        const response = await fetch(syncLambdaUrl, {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
          },
          body: JSON.stringify({
            event: 'product.updated',
            product: {
              id: result.id
            }
          })
        });
        
        const data = await response.json();
        strapi.log.info(`Commerce Layer synchronization completed: ${JSON.stringify(data)}`);
      } catch (error) {
        strapi.log.error(`Error in Commerce Layer synchronization: ${error.message}`);
      }
    });
    
    // Register a hook to send updates to Commerce Layer when a product is created
    strapi.hooks.addHook('afterCreate.product', async (result) => {
      try {
        // Get the sync Lambda URL from the environment
        const syncLambdaUrl = process.env.SYNC_LAMBDA_URL;
        
        if (!syncLambdaUrl) {
          strapi.log.error('SYNC_LAMBDA_URL not configured in environment');
          return;
        }
        
        // Send a request to the sync Lambda
        const response = await fetch(syncLambdaUrl, {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
          },
          body: JSON.stringify({
            event: 'product.created',
            product: {
              id: result.id
            }
          })
        });
        
        const data = await response.json();
        strapi.log.info(`Commerce Layer synchronization completed: ${JSON.stringify(data)}`);
      } catch (error) {
        strapi.log.error(`Error in Commerce Layer synchronization: ${error.message}`);
      }
    });
    
    // Register a hook to send updates to Commerce Layer when a product is deleted
    strapi.hooks.addHook('afterDelete.product', async (result) => {
      try {
        // Get the sync Lambda URL from the environment
        const syncLambdaUrl = process.env.SYNC_LAMBDA_URL;
        
        if (!syncLambdaUrl) {
          strapi.log.error('SYNC_LAMBDA_URL not configured in environment');
          return;
        }
        
        // Send a request to the sync Lambda
        const response = await fetch(syncLambdaUrl, {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
          },
          body: JSON.stringify({
            event: 'product.deleted',
            product: {
              id: result.id
            }
          })
        });
        
        const data = await response.json();
        strapi.log.info(`Commerce Layer synchronization completed: ${JSON.stringify(data)}`);
      } catch (error) {
        strapi.log.error(`Error in Commerce Layer synchronization: ${error.message}`);
      }
    });
  }
};

GraphQL Plugin Configuration

To expose our content through GraphQL, let's configure the GraphQL plugin:

// config/plugins.js (addition)
module.exports = {
  // ... other configurations
  
  // GraphQL Plugin
  graphql: {
    enabled: true,
    config: {
      endpoint: '/graphql',
      shadowCRUD: true,
      playgroundAlways: false,
      depthLimit: 7,
      amountLimit: 100,
      apolloServer: {
        tracing: false,
      },
    },
  },
};

Upload Plugin Configuration for AWS S3

To use AWS S3 as a storage provider for uploaded files, let's configure the Upload plugin:

// config/plugins.js (addition)
module.exports = {
  // ... other configurations
  
  // Upload Plugin
  upload: {
    config: {
      provider: 'aws-s3',
      providerOptions: {
        accessKeyId: process.env.AWS_ACCESS_KEY_ID,
        secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
        region: process.env.AWS_REGION,
        params: {
          Bucket: process.env.AWS_BUCKET_NAME,
        },
      },
      actionOptions: {
        upload: {},
        uploadStream: {},
        delete: {},
      },
    },
  },
};

Initial Data for ServerlessDay Merchandising

To initialize our CMS with some sample data, let's create a seed file:

// src/seed/index.js
module.exports = async () => {
  try {
    // Create categories
    const categories = [
      {
        name: 'T-Shirts',
        description: 'ServerlessDay T-shirts in various colors and sizes',
      },
      {
        name: 'Hats',
        description: 'Hats with ServerlessDay logo',
      },
      {
        name: 'Stickers',
        description: 'Stickers with ServerlessDay logos and slogans',
      },
      {
        name: 'Keychains',
        description: 'ServerlessDay themed keychains',
      },
    ];
    
    for (const category of categories) {
      await strapi.entityService.create('api::category.category', {
        data: {
          ...category,
          publishedAt: new Date(),
        },
      });
    }
    
    // Get created categories
    const createdCategories = await strapi.entityService.findMany('api::category.category');
    
    // Create products
    const products = [
      {
        name: 'ServerlessDay T-Shirt - Black',
        description: 'Black T-shirt with ServerlessDay logo on the front and slogan on the back',
        sku: 'TSHIRT-BLK-001',
        price: 19.99,
        stock: 100,
        colors: 'Black',
        sizes: 'S,M,L,XL',
        category: createdCategories.find(c => c.name === 'T-Shirts').id,
      },
      {
        name: 'ServerlessDay T-Shirt - Blue',
        description: 'Blue T-shirt with ServerlessDay logo on the front and slogan on the back',
        sku: 'TSHIRT-BLU-001',
        price: 19.99,
        stock: 80,
        colors: 'Blue',
        sizes: 'S,M,L,XL',
        category: createdCategories.find(c => c.name === 'T-Shirts').id,
      },
      {
        name: 'ServerlessDay Hat',
        description: 'Hat with embroidered ServerlessDay logo',
        sku: 'HAT-001',
        price: 14.99,
        stock: 50,
        colors: 'Black,Blue',
        sizes: 'One Size',
        category: createdCategories.find(c => c.name === 'Hats').id,
      },
      {
        name: 'ServerlessDay Sticker Set',
        description: 'Set of 5 stickers with ServerlessDay logos and slogans',
        sku: 'STICKER-SET-001',
        price: 4.99,
        stock: 200,
        category: createdCategories.find(c => c.name === 'Stickers').id,
      },
      {
        name: 'ServerlessDay Keychain',
        description: 'Metal keychain with ServerlessDay logo',
        sku: 'KEYCHAIN-001',
        price: 9.99,
        stock: 75,
        colors: 'Silver',
        category: createdCategories.find(c => c.name === 'Keychains').id,
      },
    ];
    
    for (const product of products) {
      await strapi.entityService.create('api::product.product', {
        data: {
          ...product,
          publishedAt: new Date(),
        },
      });
    }
    
    console.log('Seed completed successfully!');
  } catch (error) {
    console.error('Error during seed:', error);
  }
};

Running Strapi Locally

To run Strapi locally during development, we can use the following commands:

# Create a new Strapi project
npx create-strapi-app@latest serverless-ecommerce-strapi --quickstart

# Navigate to the project directory
cd serverless-ecommerce-strapi

# Install necessary dependencies
npm install @strapi/provider-upload-aws-s3

# Copy configuration files
cp .env.example .env

# Start Strapi in development mode
npm run develop

To run Strapi with Docker Compose:

# Start containers
docker-compose up -d

# View logs
docker-compose logs -f

Deployment on AWS Fargate

Deployment on AWS Fargate is automatically handled by our CDK stack. When we run cdk deploy, the StrapiServiceConstruct creates a Fargate service that runs the Strapi container. To update the service after making changes:

# Build Docker image
docker build -t strapi-cms .

# Tag image for ECR
docker tag strapi-cms:latest ${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/strapi-cms:latest

# Login to ECR
aws ecr get-login-password --region ${AWS_REGION} | docker login --username AWS --password-stdin ${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com

# Push image to ECR
docker push ${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/strapi-cms:latest

# Update Fargate service
aws ecs update-service --cluster StrapiCluster --service StrapiService --force-new-deployment

Conclusion

With Strapi now fully configured and deployed on AWS Fargate, we’ve established a flexible, scalable, and API-ready content management foundation. We've designed content models tailored to the needs of modern commerce, built a synchronization plugin to keep Commerce Layer in sync, and seeded the system with real merchandising data. This is more than headless CMS—it’s a content engine designed to fit into a composable, cloud-native architecture. commerce logic layer, exploring how Radixia Maca integrates Commerce Layer to manage pricing, inventory, and orders—completing the loop between content, intelligence, and transactions.

Share this post