Skip to content

Custom Templates

FORGE allows you to create your own templates to generate domain-specific application modules. A custom template follows the same structure as the built-in templates, producing models, migrations, API endpoints, admin pages, and permissions from a single forge template:add command.

Template Directory Structure

Custom templates live in your FORGE templates directory. Each template is a self-contained folder with a manifest and categorized source files.

~/.forge/templates/my-template/
├── manifest.yaml                    # Template metadata (required)
├── migrations/
│   └── 00030_create_my_tables.sql.tera
├── seeders/
│   └── my_permissions.sql.tera
├── backends/
│   └── rust/
│       ├── models/
│       │   ├── product.rs.tera
│       │   └── order.rs.tera
│       ├── handlers/
│       │   ├── admin/
│       │   │   └── products.rs.tera
│       │   └── products.rs.tera
│       ├── services/
│       │   └── product.rs.tera
│       └── dto/
│           └── product.rs.tera
└── frontends/
    └── nextjs/
        ├── admin/
        │   └── pages/
        │       └── products/
        │           ├── page.tsx.tera
        │           ├── columns.tsx.tera
        │           ├── create/page.tsx.tera
        │           └── [id]/
        │               ├── page.tsx.tera
        │               └── edit/page.tsx.tera
        └── web/
            └── pages/
                └── products/
                    └── page.tsx.tera

Where to place custom templates

By default, FORGE looks for custom templates in ~/.forge/templates/. You can change this location by setting the FORGE_TEMPLATES_PATH environment variable.

Defining the Manifest

The manifest.yaml file is the only required file in a template. It declares the template's metadata, dependencies, models, permissions, navigation items, and file mappings.

Basic Manifest

yaml
name: ecommerce
display_name: E-Commerce
version: 1.0.0
description: Online store with products, orders, and cart management

# Requires base template
depends_on:
  - base

# FORGE version compatibility
forge_compatibility:
  minimum: "2.0.0"
  maximum: "3.x.x"

Models Section

Declare the models your template generates. This drives code generation and documentation.

yaml
models:
  - name: Product
    table: products
    translatable: true
    fields:
      - { name: name, type: "VARCHAR(255)", required: true }
      - { name: slug, type: "VARCHAR(255)", unique: true }
      - { name: description, type: TEXT }
      - { name: price, type: "DECIMAL(10,2)", required: true }
      - { name: sku, type: "VARCHAR(100)", unique: true }
      - { name: stock, type: INT, default: 0 }
      - { name: status, type: "VARCHAR(50)", default: "'draft'" }
      - { name: category_id, type: UUID, references: "categories(id)" }

  - name: Category
    table: categories
    translatable: true
    fields:
      - { name: name, type: "VARCHAR(100)", required: true }
      - { name: slug, type: "VARCHAR(100)", unique: true }
      - { name: parent_id, type: UUID, references: "categories(id)", nullable: true }
      - { name: sort_order, type: INT, default: 0 }

  - name: Order
    table: orders
    fields:
      - { name: order_number, type: "VARCHAR(50)", unique: true }
      - { name: user_id, type: UUID, references: "users(id)" }
      - { name: status, type: "VARCHAR(50)", default: "'pending'" }
      - { name: total, type: "DECIMAL(10,2)" }
      - { name: currency, type: "VARCHAR(3)", default: "'USD'" }
      - { name: notes, type: TEXT }

Permissions Section

Define the permissions your template requires. They are automatically seeded and assigned to the admin role.

yaml
permissions:
  - group: products
    actions: [view, create, edit, delete]
  - group: categories
    actions: [view, create, edit, delete]
  - group: orders
    actions: [view, create, edit, delete, process]

Declare admin sidebar navigation items for your template.

yaml
navigation:
  - label: E-Commerce
    icon: ShoppingCart
    children:
      - { label: Products, path: /admin/ecommerce/products, permission: products.view }
      - { label: Categories, path: /admin/ecommerce/categories, permission: categories.view }
      - { label: Orders, path: /admin/ecommerce/orders, permission: orders.view }
      - { label: Reports, path: /admin/ecommerce/reports, permission: orders.view }

File Mappings Section

Map template source files to their output destinations.

yaml
files:
  # Migrations
  - source: migrations/00030_create_ecommerce_tables.sql.tera
    destination: database/migrations/00030_create_ecommerce_tables.sql
    template: true

  # Backend models
  - source: backends/rust/models/product.rs.tera
    destination: apps/api/src/models/product.rs
    template: true

  # Backend handlers
  - source: backends/rust/handlers/admin/products.rs.tera
    destination: apps/api/src/handlers/admin/products.rs
    template: true

  # Frontend pages
  - source: frontends/nextjs/admin/pages/products/page.tsx.tera
    destination: apps/admin/app/(dashboard)/ecommerce/products/page.tsx
    template: true

  # Static files (no Tera processing)
  - source: assets/default-product.png
    destination: apps/web/public/images/default-product.png
    template: false

# Directories to create
directories:
  - apps/api/src/models
  - apps/api/src/handlers/admin
  - apps/admin/app/(dashboard)/ecommerce/products
  - apps/admin/app/(dashboard)/ecommerce/orders

File Classification

Classify files by how they should be handled during upgrades:

yaml
file_types:
  managed:    # FORGE controls entirely, auto-updated on upgrade
    - database/migrations/00030_*.sql
    - apps/api/src/routes/ecommerce.rs

  template:   # May be customized, prompt user on upgrade
    - apps/api/src/handlers/admin/products.rs
    - apps/admin/app/(dashboard)/ecommerce/**/*.tsx

  core:       # User owns completely, never touched on upgrade
    - apps/api/src/services/product.rs

Post-Generation Hooks

Run commands after template files are generated:

yaml
hooks:
  post_generate:
    - "cd apps/api && cargo check"
    - "cd apps/admin && npx tsc --noEmit"

Creating Migrations

Template migrations follow the same format as base migrations. Use Tera template syntax to insert project-specific values.

sql
-- migrations/00030_create_ecommerce_tables.sql.tera

-- Products table
CREATE TABLE IF NOT EXISTS products (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name VARCHAR(255) NOT NULL,
    slug VARCHAR(255) UNIQUE NOT NULL,
    description TEXT,
    price DECIMAL(10, 2) NOT NULL DEFAULT 0,
    sku VARCHAR(100) UNIQUE,
    stock INT NOT NULL DEFAULT 0,
    status VARCHAR(50) NOT NULL DEFAULT 'draft',
    category_id UUID REFERENCES categories(id) ON DELETE SET NULL,
    translations JSONB NOT NULL DEFAULT '{}',
    created_by UUID REFERENCES users(id),
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_products_slug ON products(slug);
CREATE INDEX idx_products_status ON products(status);
CREATE INDEX idx_products_category ON products(category_id);

-- Categories table
CREATE TABLE IF NOT EXISTS categories (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name VARCHAR(100) NOT NULL,
    slug VARCHAR(100) UNIQUE NOT NULL,
    parent_id UUID REFERENCES categories(id) ON DELETE CASCADE,
    sort_order INT NOT NULL DEFAULT 0,
    translations JSONB NOT NULL DEFAULT '{}',
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Orders table
CREATE TABLE IF NOT EXISTS orders (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    order_number VARCHAR(50) UNIQUE NOT NULL,
    user_id UUID NOT NULL REFERENCES users(id),
    status VARCHAR(50) NOT NULL DEFAULT 'pending',
    total DECIMAL(10, 2) NOT NULL DEFAULT 0,
    currency VARCHAR(3) NOT NULL DEFAULT 'USD',
    notes TEXT,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_orders_user ON orders(user_id);
CREATE INDEX idx_orders_status ON orders(status);

Migration numbering

Start your migration numbers at 00030 or higher to avoid conflicts with base template migrations (which use 00001 through 00017) and built-in templates (which use 00020 through 00029).

Creating Seeders

Seeders populate default data for your template. The most common use is seeding permissions.

sql
-- seeders/ecommerce_permissions.sql.tera

-- Seed permissions for e-commerce module
INSERT INTO permissions (id, name, display_name, "group", is_system, created_at, updated_at)
VALUES
    (gen_random_uuid(), 'products.view', '{"en": "View Products", "ar": "عرض المنتجات"}', 'products', true, NOW(), NOW()),
    (gen_random_uuid(), 'products.create', '{"en": "Create Products", "ar": "إنشاء المنتجات"}', 'products', true, NOW(), NOW()),
    (gen_random_uuid(), 'products.edit', '{"en": "Edit Products", "ar": "تعديل المنتجات"}', 'products', true, NOW(), NOW()),
    (gen_random_uuid(), 'products.delete', '{"en": "Delete Products", "ar": "حذف المنتجات"}', 'products', true, NOW(), NOW()),
    (gen_random_uuid(), 'categories.view', '{"en": "View Categories", "ar": "عرض الأقسام"}', 'categories', true, NOW(), NOW()),
    (gen_random_uuid(), 'categories.create', '{"en": "Create Categories", "ar": "إنشاء الأقسام"}', 'categories', true, NOW(), NOW()),
    (gen_random_uuid(), 'categories.edit', '{"en": "Edit Categories", "ar": "تعديل الأقسام"}', 'categories', true, NOW(), NOW()),
    (gen_random_uuid(), 'categories.delete', '{"en": "Delete Categories", "ar": "حذف الأقسام"}', 'categories', true, NOW(), NOW()),
    (gen_random_uuid(), 'orders.view', '{"en": "View Orders", "ar": "عرض الطلبات"}', 'orders', true, NOW(), NOW()),
    (gen_random_uuid(), 'orders.create', '{"en": "Create Orders", "ar": "إنشاء الطلبات"}', 'orders', true, NOW(), NOW()),
    (gen_random_uuid(), 'orders.edit', '{"en": "Edit Orders", "ar": "تعديل الطلبات"}', 'orders', true, NOW(), NOW()),
    (gen_random_uuid(), 'orders.delete', '{"en": "Delete Orders", "ar": "حذف الطلبات"}', 'orders', true, NOW(), NOW())
ON CONFLICT (name) DO NOTHING;

-- Assign all new permissions to admin role
INSERT INTO permission_role (permission_id, role_id)
SELECT p.id, r.id
FROM permissions p
CROSS JOIN roles r
WHERE r.name = 'admin'
  AND p."group" IN ('products', 'categories', 'orders')
ON CONFLICT DO NOTHING;

Creating Models

Backend models use Tera templates with access to the project context. Here is an example Rust model:

rust
// backends/rust/models/product.rs.tera

use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "products")]
pub struct Model {
    #[sea_orm(primary_key, auto_increment = false)]
    pub id: Uuid,
    pub name: String,
    pub slug: String,
    pub description: Option<String>,
    #[sea_orm(column_type = "Decimal(Some((10, 2)))")]
    pub price: Decimal,
    pub sku: Option<String>,
    pub stock: i32,
    pub status: String,
    pub category_id: Option<Uuid>,
    #[sea_orm(column_type = "JsonBinary")]
    pub translations: Json,
    pub created_by: Option<Uuid>,
    pub created_at: DateTimeWithTimeZone,
    pub updated_at: DateTimeWithTimeZone,
}

#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
    #[sea_orm(
        belongs_to = "super::category::Entity",
        from = "Column::CategoryId",
        to = "super::category::Column::Id"
    )]
    Category,
}

impl Related<super::category::Entity> for Entity {
    fn to() -> RelationDef {
        Relation::Category.def()
    }
}

impl ActiveModelBehavior for ActiveModel {}

Creating Admin Pages

Frontend pages are Next.js TSX files rendered through Tera:

tsx
// frontends/nextjs/admin/pages/products/page.tsx.tera

"use client";

import { useQuery } from "@tanstack/react-query";
import { DataTable } from "@/components/data-table";
import { columns } from "./columns";
import { api } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Plus } from "lucide-react";
import Link from "next/link";
{% raw %}
export default function ProductsPage() {
  const { data, isLoading } = useQuery({
    queryKey: ["products"],
    queryFn: () => api.get("/admin/products"),
  });

  return (
    <div className="space-y-4">
      <div className="flex items-center justify-between">
        <h1 className="text-2xl font-bold">Products</h1>
        <Link href="/ecommerce/products/create">
          <Button>
            <Plus className="mr-2 h-4 w-4" />
            Add Product
          </Button>
        </Link>
      </div>

      <DataTable
        columns={columns}
        data={data?.items ?? []}
        isLoading={isLoading}
        pagination={data?.pagination}
      />
    </div>
  );
}
{% endraw %}

Escaping Tera syntax in JSX

When your template files contain JSX curly braces ({}), wrap the entire TSX content in {% raw %}...{% endraw %} blocks to prevent Tera from interpreting JSX expressions as template variables.

Testing Templates

Before publishing, validate your template to ensure it generates correctly.

Validate template structure

bash
forge validate:template ~/.forge/templates/ecommerce

This checks:

  • manifest.yaml exists and is valid
  • All files referenced in the manifest exist
  • Migration numbers do not conflict with base or other templates
  • Permission names follow the group.action convention
  • Navigation paths are unique

Test generation

bash
# Generate a test project with your template
forge new --name=test-ecommerce --template=ecommerce

# Verify backend compiles
cd test-ecommerce/apps/api && cargo check

# Verify frontend compiles
cd test-ecommerce/apps/admin && npx tsc --noEmit

# Run migrations
forge migrate

# Start and verify
forge serve

Run the full validation suite

bash
forge validate:template ~/.forge/templates/ecommerce --strict

The strict mode additionally checks:

  • All models have corresponding handlers, services, and DTOs
  • All admin pages reference valid API endpoints
  • All permissions are used in at least one handler
  • Translations exist for all display names

Publishing Templates

Share via Git repository

Package your template as a Git repository that others can clone:

bash
# Users install by cloning to their templates directory
git clone https://github.com/yourname/forge-ecommerce.git \
  ~/.forge/templates/ecommerce

Share via FORGE registry

Publish to the FORGE template registry for discoverability:

bash
# Publish your template
forge template:publish ~/.forge/templates/ecommerce

# Others can then install it
forge template:add ecommerce

Versioning

Follow semantic versioning in your manifest.yaml. When users upgrade, FORGE uses the file_types classification to determine which files to update, prompt about, or skip.

yaml
version: 1.2.0  # major.minor.patch
  • Patch (1.0.x): Bug fixes, no schema changes
  • Minor (1.x.0): New features, backward-compatible schema changes
  • Major (x.0.0): Breaking changes, requires migration

Complete Manifest Reference

yaml
# manifest.yaml - Complete reference

name: ecommerce                        # Template identifier (required)
display_name: E-Commerce               # Human-readable name (required)
version: 1.0.0                         # Semantic version (required)
description: Online store module       # Short description (required)

depends_on:                            # Template dependencies
  - base

forge_compatibility:                   # FORGE version range
  minimum: "2.0.0"
  maximum: "3.x.x"

models: [...]                          # Model definitions (see above)
permissions: [...]                     # Permission definitions
navigation: [...]                      # Admin sidebar items
files: [...]                           # File source-to-destination mappings
directories: [...]                     # Directories to create
file_types:                            # Upgrade classification
  managed: [...]
  template: [...]
  core: [...]
hooks:                                 # Post-generation commands
  post_generate: [...]

Next Steps

Released under the MIT License.