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.teraWhere 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
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.
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.
permissions:
- group: products
actions: [view, create, edit, delete]
- group: categories
actions: [view, create, edit, delete]
- group: orders
actions: [view, create, edit, delete, process]Navigation Section
Declare admin sidebar navigation items for your template.
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.
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/ordersFile Classification
Classify files by how they should be handled during upgrades:
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.rsPost-Generation Hooks
Run commands after template files are generated:
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.
-- 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.
-- 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:
// 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:
// 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
forge validate:template ~/.forge/templates/ecommerceThis checks:
manifest.yamlexists 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.actionconvention - Navigation paths are unique
Test generation
# 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 serveRun the full validation suite
forge validate:template ~/.forge/templates/ecommerce --strictThe 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:
# Users install by cloning to their templates directory
git clone https://github.com/yourname/forge-ecommerce.git \
~/.forge/templates/ecommerceShare via FORGE registry
Publish to the FORGE template registry for discoverability:
# Publish your template
forge template:publish ~/.forge/templates/ecommerce
# Others can then install it
forge template:add ecommerceVersioning
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.
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
# 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
- Template System Overview -- Understand the template architecture
- Base Template -- Review the foundation your template extends
- Available Templates -- See examples of built-in templates