Skip to main content

Building a CRUD Application

This guide walks you through building a complete CRUD (Create, Read, Update, Delete) application in Stack9. We'll build a Product Management System with categories, inventory tracking, and pricing.

What You'll Build

By the end of this guide, you'll have:

  • ✅ Product entity with fields for name, SKU, price, category
  • ✅ Category entity for organizing products
  • ✅ List view showing all products with filtering
  • ✅ Detail view for viewing/editing individual products
  • ✅ Auto-generated CRUD APIs
  • ✅ Validation rules and business logic

Time Required: 45 minutes

Prerequisites

  • Stack9 instance initialized
  • Basic understanding of JSON
  • Text editor or IDE

Step 1: Create the Category Entity

Categories organize products into logical groups.

File: src/entities/custom/category.json

{
"head": {
"name": "Category",
"key": "category",
"pluralisedName": "categories",
"allowComments": false,
"allowTasks": false,
"isActive": true
},
"fields": [
{
"label": "Name",
"key": "name",
"type": "TextField",
"placeholder": "Enter category name",
"description": "Category name (e.g., Electronics, Clothing)",
"validateRules": {
"required": true,
"maxLength": 100,
"minLength": 2
},
"index": true
},
{
"label": "Description",
"key": "description",
"type": "TextField",
"placeholder": "Enter category description",
"validateRules": {
"maxLength": 500
}
},
{
"label": "Is Active",
"key": "is_active",
"type": "Checkbox",
"defaultValue": true,
"description": "Whether this category is active"
}
],
"hooks": []
}

What This Does:

  • Defines a Category entity with 3 fields
  • name is required, indexed for fast searching
  • is_active allows soft-disabling categories
  • Stack9 auto-creates: database table, REST APIs, TypeScript types

Step 2: Create the Product Entity

File: src/entities/custom/product.json

{
"head": {
"name": "Product",
"key": "product",
"pluralisedName": "products",
"allowComments": true,
"allowTasks": true,
"isActive": true,
"maxAttachmentSizeAllowed": 10000000,
"acceptedAttachmentFileTypes": [
"image/jpg",
"image/jpeg",
"image/png"
]
},
"fields": [
{
"label": "SKU",
"key": "sku",
"type": "TextField",
"placeholder": "e.g., PROD-001",
"description": "Stock Keeping Unit - unique product identifier",
"validateRules": {
"required": true,
"maxLength": 50,
"pattern": "^[A-Z0-9-]+$"
},
"behaviourOptions": {
"readOnly": false
},
"index": true
},
{
"label": "Product Name",
"key": "name",
"type": "TextField",
"placeholder": "Enter product name",
"validateRules": {
"required": true,
"maxLength": 200,
"minLength": 3
},
"index": true
},
{
"label": "Description",
"key": "description",
"type": "RichTextEditor",
"description": "Detailed product description"
},
{
"label": "Category",
"key": "category_id",
"type": "SingleDropDown",
"relationshipOptions": {
"ref": "category"
},
"typeOptions": {
"label": "name"
},
"validateRules": {
"required": true
}
},
{
"label": "Price",
"key": "price",
"type": "NumericField",
"placeholder": "0.00",
"description": "Product price in dollars",
"validateRules": {
"required": true,
"min": 0
},
"typeOptions": {
"precision": 2
}
},
{
"label": "Cost",
"key": "cost",
"type": "NumericField",
"placeholder": "0.00",
"description": "Product cost (for margin calculation)",
"validateRules": {
"required": false,
"min": 0
},
"typeOptions": {
"precision": 2
}
},
{
"label": "Stock Quantity",
"key": "stock_quantity",
"type": "NumericField",
"placeholder": "0",
"description": "Current stock level",
"defaultValue": 0,
"validateRules": {
"required": true,
"min": 0
},
"typeOptions": {
"precision": 0
}
},
{
"label": "Reorder Level",
"key": "reorder_level",
"type": "NumericField",
"placeholder": "10",
"description": "Minimum stock level before reordering",
"defaultValue": 10,
"typeOptions": {
"precision": 0
}
},
{
"label": "Status",
"key": "status",
"type": "OptionSet",
"typeOptions": {
"values": ["Active", "Discontinued", "Out of Stock"]
},
"defaultValue": "Active",
"validateRules": {
"required": true
}
},
{
"label": "Is Featured",
"key": "is_featured",
"type": "Checkbox",
"defaultValue": false,
"description": "Show this product prominently"
}
],
"hooks": []
}

What This Does:

  • Creates Product entity with 11 fields
  • Foreign key to Category (category_id)
  • Price and cost tracking for margins
  • Stock management fields
  • Status field with predefined values
  • Allows image attachments (product photos)

Step 3: Run Stack9 to Generate Resources

# In your Stack9 instance directory
npm run dev

# Stack9 automatically:
# 1. Creates database tables
# 2. Generates TypeScript models
# 3. Creates REST API endpoints
# 4. Registers entities in the system

Generated APIs (automatic):

# Category APIs
GET /api/category/:id # Get category
POST /api/category # Create category
PUT /api/category/:id # Update category
DELETE /api/category/:id # Delete category
POST /api/category/search # Search categories

# Product APIs
GET /api/product/:id # Get product
POST /api/product # Create product
PUT /api/product/:id # Update product
DELETE /api/product/:id # Delete product
POST /api/product/search # Search products

Step 4: Create Category List Screen

File: src/screens/category_list.json

{
"head": {
"title": "Categories",
"key": "category_list",
"route": "category-list",
"app": "products",
"icon": "FolderOutlined"
},
"screenType": "listView",
"listQuery": "getcategorylist",
"entityKey": "category",
"columnsConfiguration": [
{
"field": "name",
"label": "Category Name",
"value": "{{name}}",
"renderAs": "Text",
"options": {
"linkProp": "/products/category-detail/{{id}}"
}
},
{
"field": "description",
"label": "Description",
"value": "{{description}}",
"renderAs": "Text"
},
{
"field": "is_active",
"label": "Status",
"value": "{{is_active}}",
"renderAs": "EnumTags",
"options": {
"enumColor": {
"true": "green",
"false": "red"
}
}
},
{
"field": "_created_at",
"label": "Created",
"value": "{{_created_at}}",
"renderAs": "Date",
"options": {
"format": "DD MMM YYYY"
}
}
],
"actions": [
{
"key": "create",
"label": "Create Category",
"type": "openDrawer",
"drawerKey": "category_create_screen"
}
]
}

Step 5: Create Product List Screen

File: src/screens/product_list.json

{
"head": {
"title": "Products",
"key": "product_list",
"route": "product-list",
"app": "products",
"icon": "ShoppingOutlined"
},
"screenType": "listView",
"listQuery": "getproductlist",
"entityKey": "product",
"columnsConfiguration": [
{
"field": "sku",
"label": "SKU",
"value": "{{sku}}",
"renderAs": "Text",
"options": {
"linkProp": "/products/product-detail/{{id}}"
}
},
{
"field": "name",
"label": "Product Name",
"value": "{{name}}",
"renderAs": "Text",
"options": {
"linkProp": "/products/product-detail/{{id}}"
}
},
{
"field": "category",
"label": "Category",
"value": "{{category.name}}",
"renderAs": "Text"
},
{
"field": "price",
"label": "Price",
"value": "{{price}}",
"renderAs": "Decimal",
"options": {
"prefix": "$",
"afterDecimalPoint": 2
}
},
{
"field": "stock_quantity",
"label": "Stock",
"value": "{{stock_quantity}}",
"renderAs": "Decimal",
"options": {
"afterDecimalPoint": 0
}
},
{
"field": "status",
"label": "Status",
"value": "{{status}}",
"renderAs": "EnumTags",
"options": {
"enumColor": {
"Active": "green",
"Discontinued": "red",
"Out of Stock": "orange"
}
}
},
{
"field": "is_featured",
"label": "Featured",
"value": "{{is_featured}}",
"renderAs": "EnumTags",
"options": {
"enumColor": {
"true": "blue",
"false": "gray"
}
}
}
],
"actions": [
{
"key": "create",
"label": "Create Product",
"type": "openDrawer",
"drawerKey": "product_create_screen"
},
{
"key": "export",
"label": "Export to CSV",
"type": "export"
}
],
"filters": [
{
"field": "category_id",
"label": "Category",
"type": "dropdown",
"entityKey": "category"
},
{
"field": "status",
"label": "Status",
"type": "dropdown",
"options": ["Active", "Discontinued", "Out of Stock"]
}
]
}

Step 6: Create Query Library Entries

Query library entries define how data is fetched and filtered. For CRUD applications, you'll typically need queries for listing records with filters and fetching individual records.

Category List Query

File: src/query-library/getcategorylist.json

{
"key": "getcategorylist",
"name": "getCategoryList",
"connector": "stack9_api",
"queryTemplate": {
"method": "post",
"path": "/category/search",
"bodyParams": "{\n \"$select\": [\"id\", \"name\", \"description\", \"is_active\", \"_created_at\"],\n \"$where\": {\n \"_is_deleted\": false\n },\n \"$sort\": {\n \"name\": \"asc\"\n } ]\n}",
"queryParams": {
"page": "{{page}}",
"limit": "{{limit}}"
}
},
"userParams": {
"page": "0",
"limit": "10"
}
}

Product List Query with Filters

File: src/query-library/getproductlist.json

{
"key": "getproductlist",
"name": "getProductList",
"connector": "stack9_api",
"queryTemplate": {
"method": "post",
"path": "/product/search",
"bodyParams": "{\n \"$select\": [\n \"id\",\n \"sku\",\n \"name\",\n \"category_id\",\n \"category.name\",\n \"price\",\n \"stock_quantity\",\n \"status\",\n \"is_featured\"\n ],\n \"$where\": {\n \"_is_deleted\": false\n },\n \"$withRelated\": [\"category(notDeleted)\"],\n \"$sort\": {\n \"name\": \"asc\"\n }\n}",
"queryParams": {
"page": "{{page}}",
"limit": "{{limit}}"
}
},
"filters": [
{
"name": "Category",
"key": "category",
"typeQueryFilter": "array",
"field": "category_id",
"typeFilter": "MultiDropDown",
"useSubquery": false,
"sequence": 1,
"dataSource": {
"type": "query",
"labelProp": "name",
"valueProp": "id",
"query": {
"name": "getCategoryList",
"queryKey": "getcategorylist"
}
}
},
{
"name": "Status",
"key": "status",
"typeQueryFilter": "array",
"field": "status",
"typeFilter": "MultiDropDown",
"useSubquery": false,
"sequence": 2,
"dataSource": {
"type": "static",
"options": [
{ "label": "Active", "value": "Active" },
{ "label": "Discontinued", "value": "Discontinued" },
{ "label": "Out of Stock", "value": "Out of Stock" }
]
}
},
{
"name": "Price Range",
"key": "price_range",
"typeQueryFilter": "between",
"field": "price",
"typeFilter": "NumericRangeValue",
"useSubquery": false,
"sequence": 3
},
{
"name": "Featured Only",
"key": "featured",
"typeQueryFilter": "compare",
"field": "is_featured",
"typeFilter": "BooleanValue",
"useSubquery": false,
"sequence": 4
}
],
"querySearchFields": ["name", "sku", "description"],
"userParams": {
"page": "0",
"limit": "20"
}
}

Key Points About Filters:

  • filters array: Defines the filter UI components that appear in the list view
  • typeQueryFilter: Specifies how the filter operates (compare, between, array)
  • typeFilter: Determines the UI component (MultiDropDown, NumericRangeValue, BooleanValue, etc.)
  • dataSource: Can be static options or fetched from another query
  • querySearchFields: Enables text search across multiple fields

Step 7: Create Detail Screens

File: src/screens/product_detail.json

{
"head": {
"title": "Product Details",
"key": "product_detail",
"route": "product-detail/:id",
"app": "products"
},
"screenType": "detailView",
"detailQuery": "getproduct",
"entityKey": "product",
"sections": [
{
"title": "Basic Information",
"fields": ["sku", "name", "description", "category_id", "status"]
},
{
"title": "Pricing",
"fields": ["price", "cost"]
},
{
"title": "Inventory",
"fields": ["stock_quantity", "reorder_level"]
},
{
"title": "Settings",
"fields": ["is_featured"]
}
],
"actions": [
{
"key": "edit",
"label": "Edit",
"type": "openDrawer",
"drawerKey": "product_edit_screen"
},
{
"key": "delete",
"label": "Delete",
"type": "delete",
"confirmMessage": "Are you sure you want to delete this product?"
}
]
}

File: src/query-library/getproduct.json

{
"key": "getproduct",
"name": "getProduct",
"connector": "stack9_api",
"queryTemplate": {
"method": "post",
"path": "/product/search",
"bodyParams": "{\n \"$select\": [\"*\", \"category.name\"],\n \"$where\": {\n \"id\": {{id}}\n },\n \"$withRelated\": [\"category(notDeleted)\"]\n}",
"queryParams": {}
},
"userParams": {
"id": ""
}
}

Step 8: Create App Navigation

File: src/apps/products.json

{
"name": "Products",
"key": "products",
"description": "Product and category management",
"nodeType": "appNode",
"icon": "ShoppingOutlined",
"theme": "default",
"visibility": "PUBLIC",
"order": 20,
"children": [
{
"key": "product_list",
"name": "Products",
"nodeType": "link",
"link": "/products/product-list"
},
{
"key": "category_list",
"name": "Categories",
"nodeType": "link",
"link": "/products/category-list"
}
]
}

Step 9: Add Validation Logic (Optional)

File: src/entity-hooks/product.vat.ts

import {
CustomFunction,
CustomFunctionContext,
CustomFunctionResponse,
HookOperation,
} from '@april9au/stack9-sdk';
import { DBProduct } from '../models/stack9/Product';

export class ValidateProduct extends CustomFunction {
constructor(private context: CustomFunctionContext<DBProduct>) {
super();
}

entityName = 'product';

async exec(): Promise<CustomFunctionResponse> {
const { entity, operation, db } = this.context;

// Validate SKU is unique
if (operation === HookOperation.create || entity.sku) {
const existing = await db.entity.knex('products')
.where({ sku: entity.sku })
.whereNot({ id: entity.id || 0 })
.where({ _is_deleted: false })
.first();

if (existing) {
return {
valid: false,
errors: [{
field: 'sku',
message: 'SKU already exists'
}]
};
}
}

// Validate price is greater than cost
if (entity.price && entity.cost && entity.price < entity.cost) {
return {
valid: false,
errors: [{
field: 'price',
message: 'Price must be greater than cost'
}]
};
}

// Auto-update status based on stock
let status = entity.status;
if (entity.stock_quantity !== undefined) {
if (entity.stock_quantity === 0) {
status = 'Out of Stock';
} else if (status === 'Out of Stock' && entity.stock_quantity > 0) {
status = 'Active';
}
}

return {
valid: true,
entity: {
...entity,
status
}
};
}
}

Step 10: Test Your Application

# Start Stack9
npm run dev

# Navigate to:
http://localhost:3000/products/category-list
http://localhost:3000/products/product-list

Test CRUD Operations

Create:

  1. Click "Create Category"
  2. Fill in: Name = "Electronics", Description = "Electronic devices"
  3. Save
  4. Click "Create Product"
  5. Fill in: SKU = "ELEC-001", Name = "Laptop", Category = "Electronics", Price = 999.99
  6. Save

Read:

  1. View product list - see your laptop
  2. Click on SKU to view details
  3. See all product information

Update:

  1. Click "Edit" on product detail
  2. Change price to 899.99
  3. Save
  4. Verify price updated

Delete:

  1. Click "Delete" on product
  2. Confirm deletion
  3. Verify product removed from list

Working with Filters

Filters enhance the user experience by allowing data refinement. Here's how filters work in your CRUD application:

Filter Types Available

Your product list now supports:

  • Category Filter - Multi-select dropdown pulling from categories
  • Status Filter - Multi-select with static options
  • Price Range - Min/max numeric inputs
  • Featured Toggle - Boolean yes/no filter
  • Text Search - Search across name, SKU, and description

How Filters Appear in the UI

When users access the product list, they'll see:

  1. Search Box at the top (from querySearchFields)
  2. Filter Panel on the left with all defined filters
  3. Applied Filters shown as badges
  4. Result Count updating as filters are applied

Adding More Filters

To add a stock level filter, update getproductlist.json:

{
"name": "Stock Level",
"key": "stock_level",
"typeQueryFilter": "compare",
"field": "stock_quantity",
"typeFilter": "SingleDropDown",
"useSubquery": false,
"sequence": 5,
"dataSource": {
"type": "static",
"options": [
{ "label": "Out of Stock (0)", "value": "0" },
{ "label": "Low Stock (1-10)", "value": "1-10" },
{ "label": "In Stock (10+)", "value": "10+" }
]
}
}

Filter Best Practices

  1. Group Related Filters - Use filterSections to organize
  2. Set Sensible Defaults - Use userParams for initial values
  3. Index Filter Fields - Add database indexes for performance
  4. Limit Options - Don't load thousands of dropdown options

For comprehensive filter documentation, see Query Filters and Search.

What You've Built

You now have a complete CRUD application with:

Entities:

  • Category entity with 3 fields
  • Product entity with 11 fields
  • Foreign key relationship (Product → Category)

Screens:

  • Category list with create action
  • Product list with filters, search, and export
  • Product detail with sections
  • Auto-generated create/edit forms

Query Features:

  • Advanced filtering with multiple filter types
  • Text search across fields
  • Pagination support
  • Dynamic filter data sources

APIs:

  • 10 REST endpoints (5 per entity)
  • Search, filter, and sort capabilities
  • Relationship loading

Business Logic:

  • SKU uniqueness validation
  • Price/cost validation
  • Auto-status updates based on stock
  • Soft deletes

Next Steps

Enhance your application with:

  1. Add Automation - Notify when stock is low

  2. Add Search - Full-text search across products

  3. Add Reports - Sales and inventory reports

  4. Add Images - Product photo uploads

    • Configure attachments in entity head
  5. Add Pricing Tiers - Volume discounts

    • Create price_tier entity with Grid relationship

Troubleshooting

Entity Not Appearing

Problem: Created entity but not seeing it in UI

Solution:

  1. Restart Stack9: npm run dev
  2. Check entity file is in src/entities/custom/
  3. Check JSON is valid
  4. Check logs for errors

Query Not Working

Problem: List screen shows "No data"

Solution:

  1. Check query exists in src/query-library/
  2. Verify connector is set to stack9_api
  3. Test API directly: POST /api/product/search
  4. Check browser console for errors

Relationship Not Loading

Problem: Category name not showing in product list

Solution:

  1. Add to $withRelated: ["category(notDeleted)"]
  2. Add to $select: ["category.name"]
  3. Use dot notation in column: {{category.name}}

Complete Code

All code from this guide is available at:

Summary

In 45 minutes, you've built a production-ready CRUD application with:

  • Database schema
  • REST APIs
  • Admin UI
  • Business logic
  • Validation

No backend code written! Everything is configuration-driven, type-safe, and maintainable.