Template System
FORGE uses the Tera template engine to generate source code. Every file in a generated project originates from a .tera template that is processed with variables derived from the project's forge.yaml configuration. This architecture makes FORGE's output fully deterministic: the same configuration always produces the same code.
How Templates Work
When you run forge new or any forge make:* command, FORGE follows this pipeline:
- Read configuration from
forge.yaml(app name, features, languages, providers) - Build a context object containing all template variables
- Locate the relevant
.teratemplates from the templates directory - Render each template through the Tera engine, replacing variables and evaluating conditionals
- Write the output to the project directory with the
.teraextension stripped
forge.yaml ──→ Context Variables ──→ Tera Engine ──→ Generated Code
↑
.tera TemplatesTemplate Directory Structure
Templates are organized by backend framework and frontend framework, with shared templates for cross-cutting concerns:
templates/
├── backends/
│ ├── rust/ ← Rust + Axum (default backend)
│ │ ├── manifest.yaml
│ │ ├── api/
│ │ │ ├── Cargo.toml.tera
│ │ │ └── src/
│ │ │ ├── main.rs.tera
│ │ │ ├── config/
│ │ │ ├── models/
│ │ │ ├── handlers/
│ │ │ │ ├── auth.rs.tera
│ │ │ │ ├── profile.rs.tera
│ │ │ │ └── admin/
│ │ │ │ ├── users.rs.tera
│ │ │ │ ├── contents.rs.tera
│ │ │ │ └── menus.rs.tera
│ │ │ ├── middleware/
│ │ │ ├── routes/
│ │ │ ├── services/
│ │ │ ├── dto/
│ │ │ ├── providers/
│ │ │ │ ├── sms/
│ │ │ │ ├── email/
│ │ │ │ ├── storage/
│ │ │ │ └── payments/
│ │ │ ├── error/
│ │ │ └── utils/
│ │ └── migrations/
│ │ ├── 00001_create_languages_table.sql.tera
│ │ ├── 00002_create_users_table.sql.tera
│ │ └── ... (17 migration templates)
│ ├── laravel/
│ ├── fastapi/
│ └── node/
│
├── frontends/
│ ├── nextjs/ ← Next.js (default frontend)
│ │ ├── web/ ← Public-facing web app
│ │ │ ├── app/
│ │ │ │ ├── layout.tsx.tera
│ │ │ │ ├── page.tsx.tera
│ │ │ │ ├── (auth)/
│ │ │ │ ├── (protected)/
│ │ │ │ └── [slug]/
│ │ │ ├── components/
│ │ │ │ ├── site-header.tsx.tera
│ │ │ │ ├── site-footer.tsx.tera
│ │ │ │ ├── dynamic-nav.tsx.tera
│ │ │ │ ├── language-switcher.tsx.tera
│ │ │ │ └── providers/
│ │ │ └── lib/
│ │ │ ├── api.ts.tera
│ │ │ └── types.ts.tera
│ │ └── admin/ ← Admin dashboard
│ │ ├── app/
│ │ │ ├── (dashboard)/
│ │ │ │ ├── users/
│ │ │ │ ├── contents/
│ │ │ │ ├── menus/
│ │ │ │ └── settings/
│ │ └── components/
│ ├── nuxtjs/
│ ├── angular/
│ └── vanilla/
│
├── modules/ ← Optional feature modules
│ ├── crm/
│ └── helpdesk/
│
└── shared/ ← Cross-cutting templates
├── config/
│ ├── forge.yaml.tera
│ └── env.example.tera
├── infra/
│ ├── docker/
│ └── caddy/
└── database/
└── seeders/File Naming Convention
The .tera extension signals that a file is a template:
| File Pattern | Meaning |
|---|---|
main.rs.tera | Rust source template, outputs main.rs |
page.tsx.tera | TypeScript React template, outputs page.tsx |
00002_create_users_table.sql.tera | SQL migration template |
Cargo.toml.tera | TOML config template |
docker-compose.yml.tera | YAML config template |
README.md.tera | Markdown template |
Files without the .tera extension are copied as-is without processing.
Template Variables
Every template receives a standard context built from forge.yaml. Here is the complete set of variables available:
Application Variables
app:
name: "myapp" # {{ app.name }}
display_name: "My Application" # {{ app.display_name }}
description: "A FORGE app" # {{ app.description }}
version: "1.0.0" # {{ app.version }}Backend & Frontend
backend:
framework: "rust" # {{ backend.framework }}
port: 8080 # {{ backend.port }}
frontend:
framework: "nextjs" # {{ frontend.framework }}
web_port: 3000 # {{ frontend.web_port }}
admin_port: 3001 # {{ frontend.admin_port }}Database
database:
driver: "postgresql" # {{ database.driver }}
host: "localhost" # {{ database.host }}
port: 5432 # {{ database.port }}
name: "myapp" # {{ database.name }}Domains
domains:
web: "myapp.test" # {{ domains.web }}
admin: "admin.myapp.test" # {{ domains.admin }}
api: "api.myapp.test" # {{ domains.api }}Authentication
auth:
method: "email_password" # {{ auth.method }}
jwt_expiry: "60m" # {{ auth.jwt_expiry }}Languages
languages:
default: "en" # {{ languages.default }}
list: # {% for lang in languages.list %}
- code: "en"
name: "English"
direction: "ltr"
- code: "ar"
name: "Arabic"
direction: "rtl"Providers
providers:
sms: "twilio" # {{ providers.sms }}
email: "smtp" # {{ providers.email }}
storage: "local" # {{ providers.storage }}FORGE Metadata
forge:
version: "2.0.0" # {{ forge.version }}
generated_at: "2026-01-21" # {{ forge.generated_at }}Custom Filters
FORGE registers custom Tera filters for common string transformations:
{{ app.name | snake_case }} → my_app
{{ app.name | pascal_case }} → MyApp
{{ app.name | kebab_case }} → my-app
{{ app.name | camel_case }} → myApp
{{ app.name | upper }} → MYAPPConditional Blocks
Templates use Tera's control flow to include or exclude code based on configuration. This is how a single template set produces different output for different projects.
Feature Conditionals
// main.rs.tera
use {{ app.name | snake_case }}::config::AppConfig;
use {{ app.name | snake_case }}::routes;
{% if feature_content %}
use {{ app.name | snake_case }}::handlers::contents;
{% endif %}
{% if feature_menus %}
use {{ app.name | snake_case }}::handlers::menus;
{% endif %}Authentication Method
// handlers/auth.rs.tera
{% if auth.method == "email_password" %}
pub async fn login(
Json(payload): Json<LoginRequest>,
) -> Result<Json<AuthResponse>, ApiError> {
let user = find_by_email(&payload.email).await?;
verify_password(&payload.password, &user.password)?;
let token = generate_jwt(&user)?;
Ok(Json(AuthResponse { user, token }))
}
{% elif auth.method == "mobile_otp" %}
pub async fn request_otp(
Json(payload): Json<OtpRequest>,
) -> Result<Json<MessageResponse>, ApiError> {
let code = generate_otp();
store_otp(&payload.phone, &code).await?;
sms_provider.send_otp(&payload.phone, &code).await?;
Ok(Json(MessageResponse { message: "OTP sent" }))
}
{% endif %}Language-Aware Templates
// layout.tsx.tera
{% if languages.list | length > 1 %}
import { LanguageSwitcher } from '@/components/language-switcher'
{% endif %}
export default function RootLayout({ children }) {
return (
<html lang="{{ languages.default }}"
{%- if languages.list | filter(attribute="direction", value="rtl") | length > 0 %}
dir={direction}
{%- endif %}>
<body>
{% if languages.list | length > 1 %}
<LanguageSwitcher />
{% endif %}
{children}
</body>
</html>
)
}Provider Selection
// providers/sms/mod.rs.tera
{% if providers.sms == "twilio" %}
mod twilio;
pub use twilio::TwilioProvider as SmsDriver;
{% elif providers.sms == "unifonic" %}
mod unifonic;
pub use unifonic::UnifonicProvider as SmsDriver;
{% else %}
mod log_provider;
pub use log_provider::LogProvider as SmsDriver;
{% endif %}Template Example: Full Walkthrough
Here is a complete example showing a migration template and what it produces.
Input Template
-- 00002_create_users_table.sql.tera
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
{% if auth.method == "mobile_otp" %}
phone VARCHAR(50) UNIQUE NOT NULL,
{% else %}
phone VARCHAR(50) UNIQUE,
{% endif %}
password VARCHAR(255) NOT NULL,
avatar VARCHAR(500),
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_users_email ON users(email);
{% if auth.method == "mobile_otp" %}
CREATE INDEX idx_users_phone ON users(phone);
{% endif %}Configuration
# forge.yaml
auth:
method: "email_password"Generated Output
-- 00002_create_users_table.sql
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
phone VARCHAR(50) UNIQUE,
password VARCHAR(255) NOT NULL,
avatar VARCHAR(500),
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_users_email ON users(email);Notice that the phone column is optional (no NOT NULL) and the phone index is omitted because the auth method is email_password, not mobile_otp.
Iteration in Templates
Templates frequently iterate over collections, such as generating code for each configured language:
-- seeders/01_languages.sql.tera
INSERT INTO languages (code, name, direction, is_default, is_active) VALUES
{% for lang in languages.list %}
('{{ lang.code }}', '{{ lang.name }}', '{{ lang.direction }}',
{{ lang.code == languages.default }}, true)
{%- if not loop.last %},{% endif %}
{% endfor %};With two languages configured, this produces:
INSERT INTO languages (code, name, direction, is_default, is_active) VALUES
('en', 'English', 'ltr', true, true),
('ar', 'Arabic', 'rtl', false, true);Template Loading
FORGE supports two modes for loading templates:
File-Based (Development)
During development, templates are read from the filesystem:
let engine = TemplateEngine::new(Path::new("templates/backends/rust"))?;This allows rapid iteration on templates without recompiling the CLI.
Embedded (Production)
In production builds, all templates are compiled into the binary using rust-embed:
use rust_embed::RustEmbed;
#[derive(RustEmbed)]
#[folder = "templates/"]
struct Templates;This makes the forge CLI a single self-contained binary with no external dependencies. Users can still override embedded templates by placing custom versions in ~/.forge/templates/.
TIP
If you need to customize a specific template for your organization, copy it to ~/.forge/templates/ with the same relative path. FORGE checks this directory first before falling back to the embedded templates.
Template Manifest
Each backend and frontend has a manifest.yaml that tells FORGE which templates to process, what directories to create, and what post-generation hooks to run:
# templates/backends/rust/manifest.yaml
name: rust
display_name: Rust (Axum)
version: 2.0.0
forge_compatibility:
minimum: "2.0.0"
maximum: "3.x.x"
files:
- source: "api/Cargo.toml.tera"
destination: "apps/api/Cargo.toml"
template: true
- source: "api/src/main.rs.tera"
destination: "apps/api/src/main.rs"
template: true
- source: "api/rust-toolchain.toml"
destination: "apps/api/rust-toolchain.toml"
template: false # copied as-is
directories:
- "apps/api/src/config"
- "apps/api/src/handlers"
- "apps/api/src/models"
- "apps/api/src/middleware"
- "apps/api/src/routes"
- "apps/api/src/services"
- "apps/api/src/dto"
- "apps/api/src/error"
- "apps/api/src/utils"
hooks:
post_generate:
- "cd apps/api && cargo check"WARNING
Do not manually edit files marked as managed in the manifest. FORGE may overwrite them during upgrades. Place custom logic in core files (such as services) that FORGE will never modify.