Build enterprise web applications through declarative configuration, not code generation.
lein scaffold products creates entity config, migrations, and hooks# Clone the template repository
git clone https://github.com/hectorqlucero/webgen.git
cd webgen
# Install template locally
lein install
# Create new project
lein new webgen myapp
cd myapp
# 1. Configure database
cp resources/private/config.clj.example resources/private/config.clj
# Edit config.clj with your database credentials
# 2. Run migrations
lein migrate
# 3. Seed database with default users
lein database
# 4. Start server
lein with-profile dev run
# Visit: http://localhost:8080
# Default credentials: admin@example.com / admin
The generated project includes four pre-configured entities to help you get started:
These demonstrate:
You can explore these examples or remove them to start fresh.
lein scaffold products
This creates:
resources/entities/products.edn - Entity configurationresources/migrations/XXX-products.*.sql - Database migrationssrc/myapp/hooks/products.clj - Hook file for customizationlein grid users → Generates 3 files (225 lines)
Customize → Regenerate → LOSE CHANGES ❌
Create users.edn (80 lines) → Refresh browser → Done ✅
Modify config → Never lose changes
| Feature | Description |
|---|---|
| No Code Generation | Pure configuration-driven - edit EDN files, not generated code |
| Hot Reload | Change configs/hooks → refresh browser (no restart needed) |
| Auto-Scaffolding | lein scaffold entity creates everything |
| Multi-Database | MySQL, PostgreSQL, SQLite with vendor-specific migrations |
| Subgrids | Master-detail relationships with modal interfaces |
| File Uploads | Automatic handling via hooks (before-save/after-load) |
| Hook System | Customize without touching framework code |
| Auto-Menu | Menu generated from entity configs |
| Modern UI | Bootstrap 5 + DataTables |
Entity configs define everything about a CRUD interface. Located in resources/entities/*.edn:
{:table :products ; Database table name
:pk :id ; Primary key column
:title "Products" ; Page title
:menu-label "Products" ; Menu display text
:menu-group :catalog ; Menu grouping (:catalog, :admin, etc.)
:menu-hidden? false ; Hide from menu (for subgrids)
;; Field definitions
:columns
[{:id :id
:label "ID"
:type :hidden} ; Hidden field (PK)
{:id :name
:label "Product Name"
:type :text ; Text input
:required true ; Validation
:placeholder "Enter product name"}
{:id :description
:label "Description"
:type :textarea ; Textarea input
:rows 5}
{:id :price
:label "Price"
:type :number ; Number input
:min 0}
{:id :category
:label "Category"
:type :select ; Dropdown
:options [{:value "electronics" :label "Electronics"}
{:value "clothing" :label "Clothing"}
{:value "food" :label "Food"}]}
{:id :active
:label "Active"
:type :checkbox} ; Checkbox
{:id :image
:label "Product Image"
:type :file}] ; File upload
;; Hook registration (all optional)
:hooks {:before-load :myapp.hooks.products/before-load
:after-load :myapp.hooks.products/after-load
:before-save :myapp.hooks.products/before-save
:after-save :myapp.hooks.products/after-save
:before-delete :myapp.hooks.products/before-delete
:after-delete :myapp.hooks.products/after-delete}
;; Subgrids (master-detail relationships)
:subgrids [{:entity :reviews ; Child entity
:fk :product_id ; Foreign key in child table
:label "Product Reviews"}]}
| Type | Description | Example |
|---|---|---|
:text | Single-line text input | Name, title, SKU |
:textarea | Multi-line text | Description, notes |
:number | Numeric input | Price, quantity |
:select | Dropdown select | Category, status |
:checkbox | Boolean checkbox | Active, featured |
:radio | Radio buttons | Size, color |
:date | Date picker | Birth date, expiry |
:file | File upload | Image, document |
:hidden | Hidden field | ID, timestamps |
Entities are automatically grouped in menus by :menu-group:
:admin → Administration:catalog → Catalog:sales → Sales:reports → Reports:system → SystemOr hide from menu with :menu-hidden? true (useful for subgrids).
Hooks let you customize behavior without modifying core code. All hooks are optional.
(ns myapp.hooks.products)
(defn before-load [params]
;; Called before querying database
;; Modify query parameters, add filters
;; params = {:entity :products :filters {...} :user {...}}
params)
(defn after-load [rows]
;; Called after loading data from database
;; Transform display data (e.g., format dates, create links)
;; rows = [{:id 1 :name "Product" :image "photo.jpg"} ...]
(map #(assoc % :image (str "<img src='/uploads/" (:image %) "'>")) rows))
(defn before-save [row]
;; Called before saving to database
;; Validate, transform, handle file uploads
;; row = {:id 1 :name "Product" :image "photo.jpg"}
(if (contains? row :image)
(assoc row :file (:image row)) ; Trigger file upload
row))
(defn after-save [row]
;; Called after saving to database
;; Send notifications, update related records
;; row = {:id 1 :name "Product" ...}
(println "Saved product:" (:id row))
row)
(defn before-delete [id]
;; Called before deleting record
;; Validate deletion, clean up related data
;; id = 123
(println "Deleting product:" id)
id)
(defn after-delete [id]
;; Called after deleting record
;; Clean up files, update related records
;; id = 123
(println "Deleted product:" id)
id)
Register hooks in entity config resources/entities/products.edn:
{:table :products
:pk :id
:title "Products"
:hooks {:before-load :myapp.hooks.products/before-load
:after-load :myapp.hooks.products/after-load
:before-save :myapp.hooks.products/before-save
:after-save :myapp.hooks.products/after-save
:before-delete :myapp.hooks.products/before-delete
:after-delete :myapp.hooks.products/after-delete}}
File Uploads:
(defn before-save [row]
(if (contains? row :imagen)
(assoc row :file (:imagen row)) ; Framework handles upload
row))
(defn after-load [rows]
(map #(assoc % :imagen (image-link (:imagen %))) rows))
Data Validation:
(defn before-save [row]
(when (< (:price row) 0)
(throw (ex-info "Price cannot be negative" {:price (:price row)})))
row)
Automatic Timestamps:
(defn before-save [row]
(assoc row :updated_at (java.time.LocalDateTime/now)))
WebGen generates vendor-specific migrations automatically.
lein scaffold products
Creates migrations for all supported databases:
001-products.mysql.up.sql / .down.sql001-products.postgresql.up.sql / .down.sql001-products.sqlite.up.sql / .down.sqlSwitch databases without code changes - just update config!
Edit resources/private/config.clj:
{:connections
{;; MySQL
:mysql {:db-type "mysql"
:db-class "com.mysql.cj.jdbc.Driver"
:db-name "//localhost:3306/mydb"
:db-user "root"
:db-pwd "password"}
;; PostgreSQL
:postgres {:db-type "postgresql"
:db-class "org.postgresql.Driver"
:db-name "//localhost:5432/mydb"
:db-user "postgres"
:db-pwd "password"}
;; SQLite (no credentials needed)
:sqlite {:db-type "sqlite"
:db-class "org.sqlite.JDBC"
:db-name "db/myapp.sqlite"}}
;; Active connection
:db :mysql ; Change to :postgres or :sqlite
;; Application settings
:port 8080
:site-name "My Application"
:uploads "./uploads/"
:max-upload-mb 5
:allowed-image-exts ["jpg" "jpeg" "png" "gif" "bmp" "webp"]}
lein migrate # Apply pending migrations
lein rollback # Rollback last migration
lein database # Seed default users
# (admin@example.com/admin, user@example.com/user, system@example.com/system)
Generated projects include comprehensive documentation:
# Project Creation
lein new webgen myapp # Create new project
cd myapp
# Database Setup
lein migrate # Run migrations
lein rollback # Rollback last migration
lein database # Seed default users
# (admin@example.com/admin, user@example.com/user, system@example.com/system)
# Development
lein with-profile dev run # Start dev server (port 8080)
# Auto-reloads on config/hook changes
lein compile # Compile project
# Scaffolding
lein scaffold products # Create entity, migrations, hooks
# - resources/entities/products.edn
# - resources/migrations/XXX-products.*.sql
# - src/myapp/hooks/products.clj
# Testing
lein test # Run tests
# Production
lein uberjar # Build standalone JAR
java -jar target/myapp.jar # Run production server
The dev server watches for changes and reloads automatically:
resources/entities/*.edn) - Reloads every 2 secondssrc/myapp/hooks/*.clj) - Reloads on file changeBuilt-in authentication and role-based access control.
After running lein database:
| Username | Password | Role | Level | Access |
|---|---|---|---|---|
| user@example.com | user | Regular User | U | Standard access |
| admin@example.com | admin | Administrator | A | Full access |
| system@example.com | system | System | S | System-level access |
⚠️ Change default passwords in production!
Login with: admin@example.com / admin
Edit src/myapp/core.clj to modify authentication logic:
(defn wrap-login [handler]
;; Customize authentication check
;; Redirect unauthorized users
...)
Configure in entity configs:
{:table :admin_only_entity
:menu-group :admin
:required-role :administrator} ; Restrict by role
While 80% of your application is configuration-driven, the framework provides full control for the remaining 20% through manual customization.
Edit src/myapp/menu.clj to add custom menu items that don't come from entities.
(def custom-nav-links
"Custom navigation links (non-dropdown)"
[{:href "/dashboard" :label "Dashboard"}
{:href "/reports" :label "Reports"}
{:href "/analytics" :label "Analytics"}])
(def custom-dropdowns
"Custom dropdown menus"
{:reports {:label "Reports"
:items [{:href "/reports/sales" :label "Sales Report"}
{:href "/reports/inventory" :label "Inventory Report"}
{:href "/reports/customers" :label "Customer Report"}]}
:tools {:label "Tools"
:items [{:href "/tools/import" :label "Import Data"}
{:href "/tools/export" :label "Export Data"}
{:href "/tools/backup" :label "Backup Database"}]}})
(defn get-menu-config
"Returns the complete menu configuration with custom overrides"
[]
(let [auto-generated (auto-menu/get-menu-config)]
{:nav-links (concat (:nav-links auto-generated) custom-nav-links)
:dropdowns (merge (:dropdowns auto-generated) custom-dropdowns)}))
Result: Custom items appear alongside entity-based menu items.
WebGen separates routes into two categories:
Edit src/myapp/routes/routes.clj for public pages:
(ns myapp.routes.routes
(:require
[compojure.core :refer [defroutes GET POST]]
[myapp.handlers.home.controller :as home]
[myapp.handlers.reports.controller :as reports]))
(defroutes open-routes
;; Built-in authentication routes
(GET "/" [] (home/main))
(GET "/home/login" [] (home/login))
(POST "/home/login" [] (home/login-user))
(GET "/home/logoff" [] (home/logoff-user))
;; Your custom public routes
(GET "/about" [] (home/about-page))
(GET "/contact" [] (home/contact-page))
(POST "/contact" [] (home/process-contact))
(GET "/api/public/data" [] (reports/public-api)))
Edit src/myapp/routes/proutes.clj for authenticated pages:
(ns myapp.routes.proutes
(:require
[compojure.core :refer [defroutes GET POST]]
[myapp.handlers.dashboard.controller :as dashboard]
[myapp.handlers.reports.controller :as reports]
[myapp.handlers.api.controller :as api]))
(defroutes proutes
;; Custom dashboards
(GET "/dashboard" [] (dashboard/main))
(GET "/dashboard/sales" [] (dashboard/sales))
(GET "/dashboard/analytics" [] (dashboard/analytics))
;; Custom reports
(GET "/reports/sales" [] (reports/sales-report))
(GET "/reports/inventory" [] (reports/inventory-report))
(POST "/reports/generate" [] (reports/generate-custom-report))
;; RESTful API endpoints
(GET "/api/customers/:id" [id] (api/get-customer id))
(POST "/api/orders" [] (api/create-order))
(PUT "/api/products/:id" [id] (api/update-product id))
;; Background jobs
(POST "/admin/sync-data" [] (api/sync-external-data))
(GET "/admin/clear-cache" [] (api/clear-cache)))
Note: All proutes require authentication automatically via middleware.
Hooks provide deep customization without modifying framework code.
(ns myapp.hooks.orders)
(defn after-load [rows]
;; Calculate derived fields
(map (fn [row]
(let [subtotal (* (:quantity row) (:price row))
tax (* subtotal 0.08)
total (+ subtotal tax)]
(assoc row
:subtotal subtotal
:tax tax
:total total
:status-badge (status-badge (:status row)))))
rows))
(defn status-badge [status]
(case status
"pending" "<span class='badge bg-warning'>Pending</span>"
"completed" "<span class='badge bg-success'>Completed</span>"
"cancelled" "<span class='badge bg-danger'>Cancelled</span>"
"<span class='badge bg-secondary'>Unknown</span>"))
(ns myapp.hooks.products)
(defn before-save [row]
;; Handle multiple file uploads
(cond-> row
;; Main product image
(contains? row :image)
(assoc :file (:image row))
;; Product thumbnail
(contains? row :thumbnail)
(assoc :file_thumb (:thumbnail row))
;; Product PDF datasheet
(contains? row :datasheet)
(assoc :file_pdf (:datasheet row))))
(defn after-save [row]
;; Generate thumbnails after save
(when (:image row)
(generate-thumbnail (:image row)))
;; Update search index
(update-search-index row)
;; Notify warehouse
(notify-inventory-system row)
row)
(ns myapp.hooks.orders)
(defn before-load [params]
;; Add user-specific filters
(let [user (:user params)
role (:role user)]
(cond
;; Admins see everything
(= role :admin)
params
;; Salespeople see only their orders
(= role :salesperson)
(assoc-in params [:filters :salesperson_id] (:id user))
;; Customers see only their orders
(= role :customer)
(assoc-in params [:filters :customer_id] (:id user))
;; Default: no access
:else
(assoc params :filters {:id -1})))) ; Returns no results
(ns myapp.hooks.invoices)
(defn before-save [row]
;; Business rule validation
(validate-invoice row)
;; Auto-calculate fields
(let [items (fetch-invoice-items (:id row))
subtotal (reduce + (map :total items))
tax (* subtotal (:tax_rate row))
total (+ subtotal tax)]
(assoc row
:subtotal subtotal
:tax tax
:total total
:updated_at (java.time.LocalDateTime/now))))
(defn validate-invoice [row]
(when (< (:total row) 0)
(throw (ex-info "Invoice total cannot be negative" {:row row})))
(when (empty? (:customer_id row))
(throw (ex-info "Customer is required" {:row row})))
(when (< (count (:items row)) 1)
(throw (ex-info "Invoice must have at least one item" {:row row}))))
(ns myapp.hooks.customers)
(defn after-delete [id]
;; Cascade delete related records
(delete-customer-addresses id)
(delete-customer-orders id)
(delete-customer-notes id)
;; Update analytics
(update-customer-count)
;; Audit log
(log-customer-deletion id)
id)
Create custom handlers for non-CRUD functionality.
src/myapp/handlers/
├── home/
│ ├── controller.clj (Route handlers)
│ ├── model.clj (Business logic)
│ └── view.clj (HTML rendering)
├── dashboard/
│ ├── controller.clj
│ ├── model.clj
│ └── view.clj
└── reports/
├── controller.clj
├── model.clj
└── view.clj
Controller (src/myapp/handlers/dashboard/controller.clj):
(ns myapp.handlers.dashboard.controller
(:require
[myapp.handlers.dashboard.model :as model]
[myapp.handlers.dashboard.view :as view]))
(defn main [request]
(let [user (get-in request [:session :user])
stats (model/get-dashboard-stats user)
recent-orders (model/get-recent-orders user 10)
alerts (model/get-alerts user)]
(view/dashboard-page stats recent-orders alerts)))
Model (src/myapp/handlers/dashboard/model.clj):
(ns myapp.handlers.dashboard.model
(:require
[myapp.models.db :as db]))
(defn get-dashboard-stats [user]
{:total-orders (db/query-one
"SELECT COUNT(*) as count FROM orders WHERE user_id = ?"
[(:id user)])
:revenue (db/query-one
"SELECT SUM(total) as sum FROM orders WHERE user_id = ?"
[(:id user)])
:pending-orders (db/query-one
"SELECT COUNT(*) as count FROM orders
WHERE user_id = ? AND status = 'pending'"
[(:id user)])})
(defn get-recent-orders [user limit]
(db/query
"SELECT * FROM orders WHERE user_id = ? ORDER BY created_at DESC LIMIT ?"
[(:id user) limit]))
(defn get-alerts [user]
(db/query
"SELECT * FROM alerts WHERE user_id = ? AND dismissed = false"
[(:id user)]))
View (src/myapp/handlers/dashboard/view.clj):
(ns myapp.handlers.dashboard.view
(:require
[myapp.layout :as layout]
[hiccup.core :refer [html]]))
(defn dashboard-page [stats recent-orders alerts]
(layout/application
"Dashboard"
(html
[:div.container.mt-4
[:h1 "Dashboard"]
;; Stats cards
[:div.row
[:div.col-md-4
[:div.card
[:div.card-body
[:h5.card-title "Total Orders"]
[:p.card-text.display-4 (:count (:total-orders stats))]]]]
[:div.col-md-4
[:div.card
[:div.card-body
[:h5.card-title "Revenue"]
[:p.card-text.display-4 "$" (:sum (:revenue stats))]]]]
[:div.col-md-4
[:div.card
[:div.card-body
[:h5.card-title "Pending Orders"]
[:p.card-text.display-4 (:count (:pending-orders stats))]]]]]
;; Recent orders table
[:div.mt-4
[:h3 "Recent Orders"]
[:table.table
[:thead
[:tr
[:th "Order ID"]
[:th "Date"]
[:th "Total"]
[:th "Status"]]]
[:tbody
(for [order recent-orders]
[:tr
[:td (:id order)]
[:td (:created_at order)]
[:td "$" (:total order)]
[:td (:status order)]])]]]])))
For complex queries beyond CRUD, use direct database access:
(ns myapp.handlers.reports.model
(:require
[myapp.models.db :as db]))
;; Simple query
(defn get-top-customers [limit]
(db/query
"SELECT c.*, SUM(o.total) as total_spent
FROM customers c
JOIN orders o ON c.id = o.customer_id
GROUP BY c.id
ORDER BY total_spent DESC
LIMIT ?"
[limit]))
;; Complex aggregation
(defn get-sales-by-month [year]
(db/query
"SELECT
DATE_TRUNC('month', created_at) as month,
COUNT(*) as order_count,
SUM(total) as revenue,
AVG(total) as avg_order_value
FROM orders
WHERE EXTRACT(YEAR FROM created_at) = ?
GROUP BY month
ORDER BY month"
[year]))
;; With parameters
(defn search-products [search-term category]
(db/query
"SELECT * FROM products
WHERE (name ILIKE ? OR description ILIKE ?)
AND category = ?
ORDER BY name"
[(str "%" search-term "%") (str "%" search-term "%") category]))
Add custom middleware in src/myapp/core.clj:
(ns myapp.core
(:require
[ring.middleware.defaults :refer [wrap-defaults site-defaults]]
[ring.middleware.session :refer [wrap-session]]))
;; Custom middleware
(defn wrap-request-logging [handler]
(fn [request]
(println "Request:" (:request-method request) (:uri request))
(let [response (handler request)]
(println "Response:" (:status response))
response)))
(defn wrap-api-authentication [handler]
(fn [request]
(let [api-key (get-in request [:headers "x-api-key"])]
(if (valid-api-key? api-key)
(handler request)
{:status 401
:headers {"Content-Type" "application/json"}
:body "{\"error\": \"Invalid API key\"}"}))))
;; Apply middleware stack
(defn app []
(-> (routes open-routes proutes)
wrap-request-logging
wrap-api-authentication
wrap-session
(wrap-defaults site-defaults)))
Override or extend the default layout in src/myapp/layout.clj:
(ns myapp.layout
(:require
[hiccup.page :refer [html5]]))
(defn custom-header [title user]
[:header.navbar.navbar-expand-lg.navbar-dark.bg-primary
[:div.container-fluid
[:a.navbar-brand {:href "/"} title]
[:div.ms-auto
[:span.text-white "Welcome, " (:username user)]
[:a.btn.btn-sm.btn-outline-light.ms-2 {:href "/home/logoff"} "Logout"]]]])
(defn custom-footer []
[:footer.bg-dark.text-white.py-3.mt-5
[:div.container
[:div.row
[:div.col-md-6
[:p "© 2025 Your Company"]]
[:div.col-md-6.text-end
[:a.text-white {:href "/about"} "About"]
" | "
[:a.text-white {:href "/contact"} "Contact"]]]]])
(defn application [title content & [user]]
(html5
[:head
[:meta {:charset "UTF-8"}]
[:meta {:name "viewport" :content "width=device-width, initial-scale=1"}]
[:title title]
[:link {:rel "stylesheet" :href "/css/bootstrap.min.css"}]
[:link {:rel "stylesheet" :href "/css/custom.css"}]]
[:body
(custom-header title user)
[:main.container.my-4 content]
(custom-footer)
[:script {:src "/js/bootstrap.bundle.min.js"}]
[:script {:src "/js/custom.js"}]]))
Create RESTful APIs alongside your CRUD interface:
(ns myapp.handlers.api.controller
(:require
[cheshire.core :as json]
[myapp.models.db :as db]))
(defn json-response [data & [status]]
{:status (or status 200)
:headers {"Content-Type" "application/json"}
:body (json/generate-string data)})
(defn get-customer [id]
(if-let [customer (db/query-one "SELECT * FROM customers WHERE id = ?" [id])]
(json-response customer)
(json-response {:error "Customer not found"} 404)))
(defn create-order [request]
(let [order-data (json/parse-string (slurp (:body request)) true)
result (db/insert! :orders order-data)]
(json-response result 201)))
(defn update-product [id request]
(let [product-data (json/parse-string (slurp (:body request)) true)
result (db/update! :products {:id id} product-data)]
(if (pos? result)
(json-response {:success true})
(json-response {:error "Product not found"} 404))))
Add to routes:
(defroutes api-routes
(GET "/api/customers/:id" [id] (api/get-customer id))
(POST "/api/orders" request (api/create-order request))
(PUT "/api/products/:id" [id :as request] (api/update-product id request)))
For template maintainers:
cd webgen # Your cloned repository
# Update version in project.clj
lein deploy clojars
Once published to Clojars, users can install directly:
lein new webgen myapp # No git clone needed
Until published, users need to clone and install locally (see Quick Start above).
MIT License - see LICENSE file for details.
Issues and pull requests welcome! This is an active project used in production environments.
Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |