Liking cljdoc? Tell your friends :D

WebGen

WebGen is a Leiningen template that generates parameter-driven Clojure web applications. Instead of generating code that you then modify, WebGen applications are driven entirely by EDN configuration files. Add an entity, refresh the browser, and the CRUD interface, menu entry, routes, and data grid are all live with no server restart.

The framework targets enterprise data applications: inventory, accounting, MRP, POS, CRM, and similar systems that revolve around structured data and business rules.


Table of Contents

  1. Installation and Quick Start
  2. Project Structure
  3. Configuration
  4. Entity Configuration
  5. Field Types and Options
  6. Hook System
  7. Validators
  8. Computed Fields
  9. Subgrids and TabGrid
  10. Custom Queries
  11. Actions and Access Control
  12. Audit Trail
  13. Multi-Database Support
  14. Migrations
  15. Scaffolding
  16. Custom Routes and Handlers
  17. Menu Customization
  18. Internationalization
  19. Authentication and Security
  20. Themes
  21. Deployment
  22. Publishing the Template
  23. Custom Dashboards and Reports

1. Installation and Quick Start

Create a New Project

lein new org.clojars.hector/webgen myapp
cd myapp

Set Up the Database

Edit resources/config/app-config.edn with your database credentials. The default configuration uses SQLite and requires no changes for local development.

lein migrate      # Create database schema
lein database     # Seed default users

Start the Server

lein with-profile dev run

Visit http://localhost:3000 and log in with admin@example.com / admin.


2. Project Structure

myapp/
├── resources/
│   ├── entities/               Entity EDN configuration files
│   ├── migrations/             Database migration SQL files
│   ├── config/
│   │   └── app-config.edn      Application and database configuration
│   ├── i18n/
│   │   ├── en.edn              English translations
│   │   └── es.edn              Spanish translations
│   └── public/                 Static assets (CSS, JS, images)
│
├── src/myapp/
│   ├── core.clj                Application entry point and middleware
│   ├── layout.clj              Page layout template
│   ├── menu.clj                Menu customization
│   ├── engine/                 Framework core (do not modify)
│   ├── hooks/                  Business logic hooks (one file per entity)
│   ├── routes/
│   │   ├── routes.clj          Public (unauthenticated) routes
│   │   └── proutes.clj         Protected (authenticated) routes
│   └── handlers/               Custom MVC handlers
│
└── project.clj

The engine/ directory is the framework itself. All customization happens in entities/, hooks/, routes/, and handlers/.


3. Configuration

Edit resources/config/app-config.edn:

{:connections
 {:sqlite   {:db-type  "sqlite"
             :db-class "org.sqlite.JDBC"
             :db-name  "db/myapp.sqlite"}

  :mysql    {:db-type  "mysql"
             :db-class "com.mysql.cj.jdbc.Driver"
             :db-name  "//localhost:3306/myapp"
             :db-user  "root"
             :db-pwd   "password"}

  :postgres {:db-type  "postgresql"
             :db-class "org.postgresql.Driver"
             :db-name  "//localhost:5432/myapp"
             :db-user  "postgres"
             :db-pwd   "password"}

  :default :sqlite   ; Connection used by entities
  :main    :sqlite}  ; Connection used by migrations

 :port            3000
 :site-name       "My Application"
 :company-name    "Acme Corp"
 :base-url        "http://0.0.0.0:3000/"
 :uploads         "./uploads/myapp/"
 :max-upload-mb   5
 :allowed-image-exts ["jpg" "jpeg" "png" "gif" "webp"]
 :theme           "sketchy"
 :tz              "US/Pacific"

 ;; Optional email
 :email-host      "smtp.example.com"
 :email-user      "user@example.com"
 :email-pwd       "password"}

To switch databases, change :default and :main to :mysql or :postgres. No other changes are required.


4. Entity Configuration

Every entity is a single EDN file in resources/entities/. The file name determines the entity key.

Complete Entity Options

{;; ── Core ──────────────────────────────────────────────────────────────
 :entity     :products          ; Unique keyword identifier
 :title      "Products"         ; Display title in UI and page headers
 :table      "products"         ; Database table name
 :connection :default           ; DB connection key from app-config.edn

 ;; ── Access Control ────────────────────────────────────────────────────
 :rights     ["U" "A" "S"]      ; Who can access: User / Admin / System
 :audit?     true               ; Enable audit trail (created_by, modified_by, etc.)
 :mode       :parameter-driven  ; Always :parameter-driven

 ;; ── Menu ──────────────────────────────────────────────────────────────
 :menu-category :catalog        ; Menu group (see categories below)
 :menu-order    10              ; Sort position within the group (lower = first)
 :menu-hidden?  false           ; true hides the entity from the menu (use for subgrids)
 :menu-icon     "bi bi-box"     ; Bootstrap Icons class

 ;; ── Fields ────────────────────────────────────────────────────────────
 :fields [...]                  ; Field definitions (see Section 5)

 ;; ── Queries ───────────────────────────────────────────────────────────
 :queries {:list "SELECT * FROM products ORDER BY name"
           :get  "SELECT * FROM products WHERE id = ?"}

 ;; ── Actions ───────────────────────────────────────────────────────────
 :actions {:new    true
           :edit   true
           :delete true}

 ;; ── Hooks ─────────────────────────────────────────────────────────────
 :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 ──────────────────────────────────────────────────────────
 :subgrids [{:entity      :reviews
             :foreign-key :product_id
             :title       "Reviews"
             :icon        "bi bi-chat"}]

 ;; ── Custom UI Renderers (advanced) ────────────────────────────────────
 :ui {:grid-fn      :myapp.views.products/custom-grid      ; Replace the list/grid view
      :form-fn      :myapp.views.products/custom-form      ; Replace the edit/new form
      :dashboard-fn :myapp.views.products/custom-dashboard ; Replace the dashboard view
      }}

The :ui map lets you replace any of the three standard views with your own Hiccup-returning function. All three keys are optional — omit any you do not need to override.

KeyReplacesFunction signature
:grid-fnList / grid view(fn [entity rows] hiccup)
:form-fnEdit / new form(fn [entity row] hiccup)
:dashboard-fnDashboard / read-only view(fn [entity rows] hiccup)

Each value is a fully-qualified keyword that resolves to a function at load time (the same syntax used for hook functions and query functions).

Custom grid example — replaces the standard DataTable with a card layout:

;; src/myapp/views/products.clj
(ns myapp.views.products)

(defn custom-grid [entity rows]
  [:div.row
   (for [row rows]
     [:div.col-md-3
      [:div.card.mb-3
       [:div.card-body
        [:h5.card-title (:name row)]
        [:p.card-text (:description row)]
        [:span.badge.bg-primary (:category_name row)]]]])])

Custom form example — replaces the standard modal form with a full-page layout:

(defn custom-form [entity row]
  (let [action (str "/admin/" (name entity) "/save")]
    [:form {:method "POST" :action action}
     [:div.row
      [:div.col-md-6
       [:label "Name"]
       [:input.form-control {:name "name" :value (:name row "")}]]
      [:div.col-md-6
       [:label "Price"]
       [:input.form-control {:type "number" :name "price" :value (:price row "")}]]]
     [:button.btn.btn-primary {:type "submit"} "Save"]]))

Custom dashboard example — replaces the standard read-only grid with a summary panel:

(defn custom-dashboard [entity rows]
  (let [total (reduce + 0 (map :amount rows))]
    [:div
     [:h4 "Total: " total]
     [:table.table
      [:thead [:tr [:th "Date"] [:th "Amount"]]]
      [:tbody
       (for [row rows]
         [:tr [:td (:date row)] [:td (:amount row)]])]]]))

Menu Categories

ValueMenu LabelTypical Entities
:clientsClientsCustomers, contacts, agents
:propertiesPropertiesReal estate, buildings, units
:financialFinancialPayments, invoices, commissions
:transactionsTransactionsOrders, sales, rentals
:documentsDocumentsContracts, attachments
:systemSystemUsers, roles, settings
:adminAdministrationAudit logs, backups
:reportsReportsDashboards, analytics

Minimal Entity

{:entity     :products
 :title      "Products"
 :table      "products"
 :connection :default
 :rights     ["U" "A" "S"]
 :menu-category :catalog
 :fields [{:id :id   :type :hidden}
          {:id :name :label "Name" :type :text :required? true}]
 :queries {:list "SELECT * FROM products ORDER BY name"
           :get  "SELECT * FROM products WHERE id = ?"}
 :actions {:new true :edit true :delete true}}

5. Field Types and Options

All Field Options

Every field map supports any combination of these keys:

{:id              :field_name      ; Required. Database column name (keyword)
 :label           "Display Label"  ; Required. UI label shown in forms and grid headers
 :type            :text            ; Required. Field type (see types below)

 ;; Validation
 :required?       true             ; Mark field as mandatory
 :validation      :myapp.validators/check-value  ; Custom validator function (namespace-qualified)

 ;; Input behavior
 :placeholder     "hint text"      ; Placeholder shown inside the input
 :value           "default"        ; Default value when creating a new record
 :maxlength       100              ; Maximum character length (text inputs)
 :min             0                ; Minimum value (numbers) or date (date inputs)
 :max             100              ; Maximum value (numbers) or date (date inputs)
 :step            0.01             ; Increment step (decimal inputs)
 :rows            5                ; Number of visible rows (textarea only)

 ;; Select / radio options
 :options         [{:value "a" :label "Option A"}]  ; Static list or function reference

 ;; Computed fields
 :compute-fn      :myapp.hooks.products/compute-total  ; Function that computes the field value

 ;; Foreign key fields
 :fk              :categories       ; Target entity keyword
 :fk-field        [:name]           ; Columns from the FK table to display (vector, can be multiple)
 :fk-sort         [:name]           ; Columns to sort the FK dropdown by
 :fk-filter       [:active "T"]     ; Filter FK options: [column value]
 :fk-parent       :estado_id        ; Parent FK field for cascading dependents
 :fk-can-create?  true              ; Allow creating new FK records via modal popup

 ;; Visibility
 :hidden-in-form? false             ; Hide from create/edit forms; still shows in grid
 :hidden-in-grid? false             ; Hide from data grid; still shows in forms
 :grid-only?      false}            ; Show only in the grid, never in forms

Text Inputs

;; Single-line text
{:id :name :label "Product Name" :type :text :required? true :maxlength 200 :placeholder "Enter name"}

;; Multi-line text
{:id :description :label "Description" :type :textarea :rows 5 :hidden-in-grid? true}

;; Email
{:id :email :label "Email" :type :email :required? true :placeholder "user@example.com"}

;; Password (value is masked)
{:id :password :label "Password" :type :password :required? true}

Numeric Inputs

;; Integer
{:id :quantity :label "Quantity" :type :number :min 0 :max 9999 :value 1}

;; Decimal / currency
{:id :price :label "Price" :type :decimal :min 0 :step 0.01 :placeholder "0.00" :required? true}

Date and Time Inputs

;; Date picker
{:id :birth_date  :label "Birth Date"    :type :date     :required? true}

;; Date and time picker
{:id :created_at  :label "Created At"    :type :datetime}

Selection Inputs

Static Dropdown

{:id      :status
 :label   "Status"
 :type    :select
 :value   "active"
 :options [{:value "active"   :label "Active"}
           {:value "inactive" :label "Inactive"}
           {:value "pending"  :label "Pending"}]}

Dropdown from Database

Create a lookup function in your model namespace:

(ns myapp.models.lookups
  (:require [myapp.models.crud :as crud]))

(defn get-categories []
  (crud/Query "SELECT id AS value, name AS label FROM categories ORDER BY name"
              :conn :default))

Reference it in the entity field:

{:id      :category_id
 :label   "Category"
 :type    :select
 :options :myapp.models.lookups/get-categories}

Foreign Key (FK) Field

An FK field renders as a searchable dropdown populated from another entity's table. No foreign key constraint is required in the database.

{:id        :category_id
 :label     "Category"
 :type      :fk
 :fk        :categories          ; Target entity keyword
 :fk-field  [:name]              ; Which columns to display in the dropdown
 :fk-sort   [:name]              ; Sort order in the dropdown
 :fk-filter [:active "T"]        ; Only show rows where active = 'T'
 :required? true
 :hidden-in-grid? true}          ; Hide the raw ID column in the grid

;; Add a display-only column to show the joined value in the grid
{:id :category_name :label "Category" :grid-only? true}

;; Both :list and :get queries must JOIN the FK table to populate the display column
:queries
{:list "SELECT pro.*, cat.name AS category_name
        FROM products pro
        LEFT JOIN categories cat ON pro.category_id = cat.id
        ORDER BY pro.name"

 :get  "SELECT pro.*, cat.name AS category_name
        FROM products pro
        LEFT JOIN categories cat ON pro.category_id = cat.id
        WHERE pro.id = ?"}

Multiple FK display columns (all joined into the dropdown label):

{:id       :agent_id
 :label    "Agent"
 :type     :fk
 :fk       :agents
 :fk-field [:first_name :last_name :phone]  ; Shows "John Doe 555-1234"
 :required? true
 :hidden-in-grid? true}
{:id :agent_name :label "Agent" :grid-only? true}

;; JOIN the agents table to populate agent_name in both views
:queries
{:list "SELECT tic.*, age.first_name || ' ' || age.last_name AS agent_name
        FROM tickets tic
        LEFT JOIN agents age ON tic.agent_id = age.id
        ORDER BY tic.created_at DESC"

 :get  "SELECT tic.*, age.first_name || ' ' || age.last_name AS agent_name
        FROM tickets tic
        LEFT JOIN agents age ON tic.agent_id = age.id
        WHERE tic.id = ?"}

Cascading Dependent FK Fields

Use :fk-parent to make one FK filter based on the value selected in another.

;; Level 1 – State
{:id        :state_id
 :label     "State"
 :type      :fk
 :fk        :states
 :fk-field  [:name]
 :fk-can-create? true
 :required?      true
 :hidden-in-grid? true}
{:id :state_name :label "State" :grid-only? true}

;; Level 2 – Municipality (filtered by selected state)
{:id        :municipality_id
 :label     "Municipality"
 :type      :fk
 :fk        :municipalities
 :fk-field  [:name]
 :fk-parent :state_id           ; Dropdown reloads when state_id changes
 :fk-can-create? true
 :hidden-in-grid? true}
{:id :municipality_name :label "Municipality" :grid-only? true}

;; Level 3 – Neighborhood (filtered by selected municipality)
{:id        :neighborhood_id
 :label     "Neighborhood"
 :type      :fk
 :fk        :neighborhoods
 :fk-field  [:name :zip_code]
 :fk-parent :municipality_id
 :fk-can-create? true
 :hidden-in-grid? true}
{:id :neighborhood_name :label "Neighborhood" :grid-only? true}

;; JOIN all three FK tables so every display column is populated in :list and :get
:queries
{:list "SELECT adr.*, sta.name AS state_name,
               mun.name AS municipality_name,
               nei.name AS neighborhood_name
        FROM addresses adr
        LEFT JOIN states         sta ON adr.state_id         = sta.id
        LEFT JOIN municipalities mun ON adr.municipality_id  = mun.id
        LEFT JOIN neighborhoods  nei ON adr.neighborhood_id  = nei.id
        ORDER BY adr.id"

 :get  "SELECT adr.*, sta.name AS state_name,
               mun.name AS municipality_name,
               nei.name AS neighborhood_name
        FROM addresses adr
        LEFT JOIN states         sta ON adr.state_id         = sta.id
        LEFT JOIN municipalities mun ON adr.municipality_id  = mun.id
        LEFT JOIN neighborhoods  nei ON adr.neighborhood_id  = nei.id
        WHERE adr.id = ?"}

Radio Buttons

{:id      :gender
 :label   "Gender"
 :type    :radio
 :value   "M"
 :options [{:id "gM" :label "Male"   :value "M"}
           {:id "gF" :label "Female" :value "F"}
           {:id "gO" :label "Other"  :value "O"}]}

;; Boolean pattern (T/F stored in database)
{:id      :active
 :label   "Active"
 :type    :radio
 :value   "T"
 :options [{:id "actT" :label "Yes" :value "T"}
           {:id "actF" :label "No"  :value "F"}]}

Checkbox

{:id    :newsletter
 :label "Subscribe to Newsletter"
 :type  :checkbox
 :value "T"}

:value is the value submitted when the checkbox is checked. When it is unchecked, an empty string "" is submitted instead (a hidden fallback input ensures the key is always present in the request params). If your database column expects 1/0 or "T"/"F", coerce the value in a before-save hook:

(defn before-save [params]
  (update params :newsletter #(if (= % "T") "T" "F")))

File Upload

{:id :image :label "Product Image" :type :file}

File uploads are handled through hooks. See the before-save and after-load hook examples in Section 6.

Hidden Field

{:id :id          :label "ID"          :type :hidden}
{:id :property_id :label "Property ID" :type :hidden}  ; FK value for subgrid child

Computed Field

A computed field displays a value calculated at runtime. It is read-only and never submitted to the database.

;; Method 1: via :compute-fn — called per row
{:id         :total
 :label      "Total"
 :type       :decimal
 :compute-fn :myapp.hooks.products/calculate-total
 :hidden-in-form? true}

;; Method 2: computed inside after-load hook — see Section 8
{:id    :days_overdue
 :label "Days Overdue"
 :type  :number
 :hidden-in-form? true}

Visibility Summary

OptionGridForm
DefaultShownShown
:hidden-in-grid? trueHiddenShown
:hidden-in-form? trueShownHidden
:grid-only? trueShownNever
:type :hiddenNeverNever

6. Hook System

Hooks are functions called at specific points in the CRUD lifecycle. Register them in the entity configuration under :hooks. All hooks are optional.

Execution Flow

LIST / GET
  before-load → [SQL query] → after-load → render

CREATE / UPDATE
  before-save → [SQL insert/update] → after-save → redirect

DELETE
  before-delete → [SQL delete] → after-delete → redirect

Hook Signatures

HookParametersReturn on successReturn on error
before-load[params]Modified params mapN/A — always returns params
after-load[rows params]Modified rows vectorN/A — always returns rows
before-save[params]Modified params map{:errors {:field "message"}}
after-save[entity-id params]{:success true}{:error "message"}
before-delete[entity-id]{:success true}{:errors {:general "message"}}
after-delete[entity-id]{:success true}{:error "message"}

before-load

Called before the database query executes. Use it to add filters, restrict access by user role, or modify query parameters.

(ns myapp.hooks.orders)

(defn before-load [params]
  (let [user  (:user params)
        level (:level user)]
    (cond
      (= level "S") params                                          ; System sees everything
      (= level "A") params                                          ; Admin sees everything
      :else (assoc-in params [:filters :salesperson_id] (:id user))))) ; Users see only their own

after-load

Called after the query returns rows. Use it to transform data, format values, add computed columns, or build HTML display values.

(defn after-load [rows params]
  (mapv (fn [row]
          (let [qty   (or (:quantity row) 0)
                price (or (:price row) 0.0)
                total (* qty price)]
            (assoc row
                   :total       total
                   :status_badge (cond
                                   (> total 1000) "High Value"
                                   (> total 500)  "Medium"
                                   :else          "Standard"))))
        rows))

File upload: convert stored filename to an HTML image tag for display in the grid.

(defn after-load [rows params]
  (mapv (fn [row]
          (if (:image row)
            (assoc row :image (str "<img src='/uploads/" (:image row) "' height='60'>"))
            row))
        rows))

before-save

Called before the insert or update. Validate data, set defaults, and transform values. Return {:errors {...}} to abort the save and display error messages to the user.

(defn before-save [params]
  (let [start (:start_date params)
        end   (:end_date params)
        price (:price params)]
    (cond
      (and start end (neg? (.compareTo end start)))
      {:errors {:end_date "End date must be after start date"}}

      (and price (<= price 0))
      {:errors {:price "Price must be greater than zero"}}

      :else
      (-> params
          (assoc :updated_at (java.time.LocalDateTime/now))
          (assoc :status (or (:status params) "pending"))))))

File upload: signal the framework to move the uploaded file by setting :file to the field containing the upload.

(defn before-save [params]
  (if (contains? params :image)
    (assoc params :file (:image params))
    params))

after-save

Called after a successful save. Use it for notifications, updating related records, or triggering background operations. Return {:success true} on completion.

(defn after-save [entity-id params]
  (try
    (when (= "processing" (:status params))
      (send-order-confirmation-email (:customer_email params) entity-id))
    (update-inventory! (:product_id params) (- (:quantity params)))
    {:success true}
    (catch Exception e
      {:error (str "Post-save error: " (.getMessage e))})))

before-delete

Called before the delete. Return {:success true} to allow the deletion or {:errors {...}} to prevent it.

(defn before-delete [entity-id]
  (let [order-count (count-related-orders entity-id)]
    (if (pos? order-count)
      {:errors {:general "Cannot delete customer with existing orders"}}
      {:success true})))

after-delete

Called after a successful delete. Use it to clean up related files, cascade deletes, or update denormalized data.

(defn after-delete [entity-id]
  (delete-related-addresses! entity-id)
  (delete-uploaded-files! entity-id)
  {:success true})

Hook Registration

{:entity :orders
 :hooks  {:before-load   :myapp.hooks.orders/before-load
          :after-load    :myapp.hooks.orders/after-load
          :before-save   :myapp.hooks.orders/before-save
          :after-save    :myapp.hooks.orders/after-save
          :before-delete :myapp.hooks.orders/before-delete
          :after-delete  :myapp.hooks.orders/after-delete}}

7. Validators

Field-Level Validator

Reference a validator function with :validation on the field:

{:id        :price
 :label     "Price"
 :type      :decimal
 :required? true
 :validation :myapp.validators/positive-price?}

The function receives [value data] where value is the field value and data is the full form map. Return true for valid, false or a string error message for invalid.

(ns myapp.validators)

(defn positive-price? [value _data]
  (and value (> value 0)))

(defn valid-zip-code? [value _data]
  (re-matches #"\d{5}" (str value)))

(defn valid-email? [value _data]
  (re-matches #".+@.+\..+" (str value)))

(defn future-date? [value _data]
  (when value
    (.isAfter value (java.time.LocalDate/now))))

Return a custom message:

(defn reasonable-price? [value _data]
  (cond
    (nil? value)  "Price is required"
    (<= value 0)  "Price must be greater than zero"
    (> value 1e7) "Price seems unreasonably high"
    :else         true))

Multi-Field Validation

For rules that span multiple fields, validate in before-save:

(defn before-save [params]
  (let [errors (cond-> {}
                 (and (:end_date params)
                      (:start_date params)
                      (.isBefore (:end_date params) (:start_date params)))
                 (assoc :end_date "End date must be after start date")

                 (and (:deposit params)
                      (< (:deposit params) 0))
                 (assoc :deposit "Deposit cannot be negative"))]
    (if (seq errors)
      {:errors errors}
      params)))

8. Computed Fields

Method 1: compute-fn (per-row function)

Define a function that receives a row map and returns the computed value:

(ns myapp.hooks.order-items)

(defn line-total [row]
  (* (or (:quantity row) 0)
     (or (:unit_price row) 0.0)))

(defn days-since-created [row]
  (when-let [d (:created_date row)]
    (.between java.time.temporal.ChronoUnit/DAYS
              d (java.time.LocalDate/now))))

Reference it in the field:

{:id         :line_total
 :label      "Line Total"
 :type       :decimal
 :compute-fn :myapp.hooks.order-items/line-total
 :hidden-in-form? true}

Method 2: after-load (batch computation)

Compute multiple fields for all rows in a single pass:

(defn after-load [rows params]
  (mapv (fn [row]
          (let [qty      (or (:quantity row) 0)
                price    (or (:unit_price row) 0.0)
                subtotal (* qty price)
                tax      (* subtotal 0.08)]
            (assoc row
                   :subtotal subtotal
                   :tax      tax
                   :total    (+ subtotal tax)
                   :full_name (str (:first_name row) " " (:last_name row)))))
        rows))

The corresponding fields in the entity must exist and be configured as display-only:

{:id :subtotal  :label "Subtotal" :type :decimal :hidden-in-form? true}
{:id :tax       :label "Tax"      :type :decimal :hidden-in-form? true}
{:id :total     :label "Total"    :type :decimal :hidden-in-form? true}
{:id :full_name :label "Name"     :type :text    :grid-only? true}

9. Subgrids and TabGrid

When an entity defines :subgrids, the UI automatically uses the TabGrid layout. The parent record is displayed as a header with tabs for each child entity. Each tab provides a full CRUD grid filtered to the selected parent record.

Subgrid Configuration

{:entity  :customers
 :title   "Customers"
 :table   "customers"
 :fields  [...]

 :subgrids [{:entity      :orders
             :foreign-key :customer_id
             :title       "Orders"
             :icon        "bi bi-cart"}

            {:entity      :addresses
             :foreign-key :customer_id
             :title       "Addresses"
             :icon        "bi bi-house"}

            {:entity      :support_tickets
             :foreign-key :customer_id
             :title       "Support"
             :icon        "bi bi-chat"}]}

Subgrid Options

OptionDescription
:entityKeyword of the child entity
:foreign-keyColumn in the child table that references the parent
:titleTab label
:iconBootstrap Icons class for the tab

Child Entity Configuration

The child entity's :queries must accept the parent ID as a parameter. The framework automatically passes it.

;; resources/entities/orders.edn
{:entity   :orders
 :table    "orders"
 :menu-hidden? true            ; Hide from main menu; appears only as a subgrid tab

 :fields [{:id :id          :type :hidden}
          {:id :customer_id :type :hidden}   ; The FK column — always hidden
          {:id :order_date  :label "Date"   :type :date}
          {:id :total       :label "Total"  :type :decimal}
          {:id :status      :label "Status" :type :select
           :options [{:value "pending"   :label "Pending"}
                     {:value "shipped"   :label "Shipped"}
                     {:value "delivered" :label "Delivered"}]}]

 :queries {:list "SELECT * FROM orders WHERE customer_id = ? ORDER BY order_date DESC"
           :get  "SELECT * FROM orders WHERE id = ?"}

 :actions {:new true :edit true :delete true}}

10. Custom Queries

The framework automatically uses exactly two query keys from the :queries map:

KeyUsed byPurpose
:listGrid / list viewReturns all rows (with optional filters from before-load)
:getEdit form / subgridReturns a single row by id

These are the only keys the framework calls automatically. Any other key you add is ignored unless you call it explicitly from a custom handler via (query/custom-query entity :your-key).

Standard Queries

:queries {:list "SELECT * FROM products ORDER BY name"
          :get  "SELECT * FROM products WHERE id = ?"}

Queries with Joins

Use :list and :get with any SQL you need, including joins:

:queries
{:list "SELECT ord.*,
               cus.name       AS customer_name,
               cus.email      AS customer_email,
               COUNT(ori.id)  AS item_count
        FROM orders ord
        LEFT JOIN customers   cus ON ord.customer_id = cus.id
        LEFT JOIN order_items ori ON ord.id = ori.order_id
        GROUP BY ord.id
        ORDER BY ord.created_at DESC"

 :get  "SELECT ord.*, cus.name AS customer_name
        FROM orders ord
        LEFT JOIN customers cus ON ord.customer_id = cus.id
        WHERE ord.id = ?"}

Function-Based Queries

When :list or :get need runtime logic (dynamic filters, user-specific data), reference a function by its fully-qualified keyword instead of a SQL string:

:queries {:list :myapp.queries.products/list-for-user
          :get  "SELECT * FROM products WHERE id = ?"}
(ns myapp.queries.products
  (:require [myapp.models.crud :as crud]))

(defn list-for-user [params conn]
  (let [user-id (get-in params [:user :id])]
    (crud/Query ["SELECT * FROM products WHERE owner_id = ? ORDER BY name"
                 user-id]
                :conn conn)))

11. Actions and Access Control

CRUD Actions

;; Full CRUD
:actions {:new true :edit true :delete true}

;; Read-only
:actions {:new false :edit false :delete false}

;; Append-only log
:actions {:new true :edit false :delete false}

User Access Levels

"U"  ; Regular user
"A"  ; Administrator
"S"  ; System

Restrict an entity to administrators and system only:

{:entity :payroll
 :rights ["A" "S"]}

Row-Level Security

Use before-load to filter rows based on the current user:

(defn before-load [params]
  (if (= "U" (get-in params [:user :level]))
    (assoc-in params [:filters :owner_id] (get-in params [:user :id]))
    params))

12. Audit Trail

Enable per-entity with :audit? true. This activates two independent mechanisms:

1. Audit Columns on the Entity Table

The framework automatically populates four columns on every save:

ColumnContent
created_byUser ID of the record creator
created_atTimestamp of creation
modified_byUser ID of the last editor
modified_atTimestamp of last modification

Note: The framework also auto-detects these columns without :audit? true. If the four columns exist in the table, they are populated on every save regardless of the flag.

These columns must exist in the database table. Add them to the migration:

-- SQLite
created_by  INTEGER,
created_at  TEXT DEFAULT (datetime('now')),
modified_by INTEGER,
modified_at TEXT

-- PostgreSQL / MySQL
created_by  INTEGER,
created_at  TIMESTAMP DEFAULT NOW(),
modified_by INTEGER,
modified_at TIMESTAMP

To show them in the UI, add them as read-only fields in the entity:

{:id :created_by  :label "Created By"  :type :hidden}
{:id :created_at  :label "Created At"  :type :hidden}
{:id :modified_by :label "Modified By" :type :hidden}
{:id :modified_at :label "Modified At" :type :hidden}

2. Audit Log Table

When :audit? true is set, the framework also writes one row to an audit_log table on every save and delete. Each row records which entity was touched, which operation was performed, a snapshot of the data, the user ID, and a timestamp.

The migration for this table ships with the template as 006-audit_log.<db>.up.sql. Run migrations to create it:

-- SQLite  (e.g. 006-audit_log.sqlite.up.sql)
CREATE TABLE IF NOT EXISTS audit_log (
  id        INTEGER PRIMARY KEY AUTOINCREMENT,
  entity    TEXT NOT NULL,
  operation TEXT NOT NULL,   -- 'create', 'update', 'delete'
  data      TEXT,            -- EDN snapshot of the record
  user_id   INTEGER,
  timestamp TEXT             -- stored as ISO-8601 string by the framework
);

-- PostgreSQL  (e.g. 006-audit_log.postgresql.up.sql)
CREATE TABLE IF NOT EXISTS audit_log (
  id        SERIAL PRIMARY KEY,
  entity    TEXT NOT NULL,
  operation TEXT NOT NULL,
  data      TEXT,
  user_id   INTEGER,
  timestamp TEXT             -- stored as ISO-8601 string by the framework
);

-- MySQL  (e.g. 006-audit_log.mysql.up.sql)
CREATE TABLE IF NOT EXISTS audit_log (
  id        INT AUTO_INCREMENT PRIMARY KEY,
  entity    VARCHAR(100) NOT NULL,
  operation VARCHAR(20)  NOT NULL,
  data      TEXT,
  user_id   INT,
  timestamp VARCHAR(50)  -- stored as ISO-8601 string by the framework
);

Connection note: the audit log is always written to the :default database connection, regardless of which connection the entity itself uses.

If the audit_log table does not exist, the framework logs a warning and continues — it will not crash.

You can expose the log as a read-only entity:

{:entity  :audit_log
 :title   "Audit Log"
 :table   "audit_log"
 :connection :default
 :rights  ["A" "S"]
 :menu-category :system
 :fields  [{:id :id        :label "ID"        :type :hidden}
           {:id :entity    :label "Entity"     :type :text}
           {:id :operation :label "Operation"  :type :text}
           {:id :user_id   :label "User"       :type :text}
           {:id :timestamp :label "Timestamp"  :type :text}
           {:id :data      :label "Data"       :type :textarea}]
 :queries {:list "SELECT * FROM audit_log ORDER BY id DESC"
           :get  "SELECT * FROM audit_log WHERE id = ?"}
 :actions {:new false :edit false :delete false}}

13. Multi-Database Support

Each entity can use a different database connection by setting :connection:

{:entity :analytics :connection :postgres}
{:entity :products  :connection :default}
{:entity :archive   :connection :mysql}

Connection keys are defined in app-config.edn. The :default key is used if :connection is omitted.


14. Migrations

WebGen uses Ragtime for schema migrations.

Migration File Naming

{number}-{name}.{database}.{direction}.sql

Examples:

001-users.sqlite.up.sql
001-users.sqlite.down.sql
001-users.mysql.up.sql
001-users.postgresql.up.sql

Commands

lein migrate                        # Apply all pending migrations
lein rollback                       # Rollback the last applied migration

To add a new migration, create the SQL files manually in resources/migrations/ following the naming convention above. For example, to add a products table create:

002-products.sqlite.up.sql
002-products.sqlite.down.sql

Then run lein migrate.

Converting Migrations Between Databases

The framework includes a converter that translates SQLite migrations to MySQL or PostgreSQL syntax:

lein convert-migrations mysql
lein convert-migrations postgresql

Type mappings applied automatically:

SQLiteMySQLPostgreSQL
INTEGER PRIMARY KEY AUTOINCREMENTINT AUTO_INCREMENT PRIMARY KEYSERIAL PRIMARY KEY
INTEGERINTINTEGER
TEXTTEXTTEXT
REALDOUBLEDOUBLE PRECISION
BLOBBLOBBYTEA
datetime('now')CURRENT_TIMESTAMPCURRENT_TIMESTAMP

Always review generated files and add indexes, constraints, and VARCHAR lengths appropriate to your target database before applying.

Copying Data Between Databases

lein copy-data mysql            # Copy all data from SQLite to MySQL
lein copy-data postgresql       # Copy all data from SQLite to PostgreSQL
lein copy-data mysql --clear    # Clear target tables before copying

Prerequisites: run lein migrate on the target database first.


15. Scaffolding

Scaffolding inspects an existing database table and generates the entity configuration and hook file. The migration must be created and applied before running scaffold — scaffold reads the live schema, it does not create migrations.

Typical Workflow

# 1. Create migration files manually in resources/migrations/
#    e.g. 002-products.sqlite.up.sql / 002-products.sqlite.down.sql

# 2. Write the CREATE TABLE SQL in the .up file
#    and the DROP TABLE SQL in the .down file

# 3. Apply the migration
lein migrate

# 4. Scaffold the entity from the live table
lein scaffold products
lein scaffold products          # Scaffold a single entity
lein scaffold --all             # Scaffold all tables in the database
lein scaffold products --force  # Overwrite existing files

Scaffolding creates:

  • resources/entities/products.edn — entity configuration
  • src/myapp/hooks/products.clj — hook file

When scaffolding child tables that have a foreign key column, the corresponding parent entity's subgrid configuration is automatically updated.


16. Custom Routes and Handlers

Public Routes

Edit src/myapp/routes/routes.clj for unauthenticated pages:

(defroutes open-routes
  (GET  "/"             [] (home/main))
  (GET  "/home/login"   [] (home/login))
  (POST "/home/login"   [] (home/login-user))
  (GET  "/home/logoff"  [] (home/logoff-user))
  (GET  "/about"        [] (home/about-page))
  (GET  "/contact"      [] (home/contact-page))
  (POST "/contact"      [] (home/process-contact)))

Protected Routes

Edit src/myapp/routes/proutes.clj for pages that require authentication. The framework applies authentication middleware automatically to all routes defined here.

(defroutes proutes
  (GET  "/dashboard"         [] (dashboard/main))
  (GET  "/reports/sales"     [] (reports/sales))
  (POST "/reports/generate"  [] (reports/generate))
  (GET  "/api/products/:id"  [id] (api/get-product id))
  (POST "/api/orders"        [] (api/create-order)))

Custom Handler (MVC Pattern)

src/myapp/handlers/
└── dashboard/
    ├── controller.clj
    ├── model.clj
    └── view.clj

Controller:

(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-stats user)]
    (view/dashboard-page stats)))

Model:

(ns myapp.handlers.dashboard.model
  (:require [myapp.models.db :as db]))

(defn get-stats [user]
  {:order-count (db/query-one "SELECT COUNT(*) AS n FROM orders WHERE user_id = ?" [(:id user)])
   :revenue     (db/query-one "SELECT SUM(total) AS s FROM orders WHERE user_id = ?" [(:id user)])})

View:

(ns myapp.handlers.dashboard.view
  (:require [myapp.layout :as layout]
            [hiccup.core  :refer [html]]))

(defn dashboard-page [stats]
  (layout/application "Dashboard"
    (html
      [:div.container.mt-4
       [:h1 "Dashboard"]
       [:p "Total orders: " (get-in stats [:order-count :n])]
       [:p "Revenue: $"     (get-in stats [:revenue :s])]])))

Direct Database Access

(ns myapp.handlers.reports.model
  (:require [myapp.models.db :as db]))

(defn top-customers [limit]
  (db/query
    "SELECT cus.name, SUM(ord.total) AS total_spent
     FROM customers cus
     JOIN orders ord ON cus.id = ord.customer_id
     GROUP BY cus.id
     ORDER BY total_spent DESC
     LIMIT ?"
    [limit]))

17. Menu Customization

The menu is generated automatically from entity configurations. To add custom entries that are not entities, edit src/myapp/menu.clj.

Custom Navigation Links

Each entry is a vector ["/path" "Label" "Rights" order]. "Rights" is optional ("U" = regular users and above, "A" = admins only, nil = everyone). order controls position among nav links (lower = first).

(def custom-nav-links
  [["/"          "Home"      nil  0]
   ["/dashboard" "Dashboard" "U" 10]
   ["/admin"     "Admin"     "A" 20]])

Custom Dropdown Menus

Each dropdown requires :id, :data-id, :label, :order, and :items. Items use the same vector format as nav links.

(def custom-dropdowns
  {:Reports
   {:id      "navdropReports"
    :data-id "Reports"
    :label   "Reports"
    :order   40
    :items   [["/reports/sales"     "Sales"     "U" 10]
              ["/reports/inventory" "Inventory" "U" 20]]}})

Merging with Auto-Generated Menu

get-menu-config in menu.clj automatically merges custom-nav-links and custom-dropdowns with the auto-generated entity menus — sorted by :order. You only need to update those two vars; do not redefine get-menu-config.


18. Internationalization

Translation files are EDN maps stored in resources/i18n/. The framework ships with en.edn (English) and es.edn (Spanish). The default locale is :es; change it in src/myapp/i18n/core.clj.

Translation keys follow a namespace convention:

  • :common/* — Reusable UI labels
  • :form/* — Form field labels
  • :validation/* — Validation messages
  • :error/* — Error messages
  • :success/* — Success messages
  • :menu/* — Menu labels (replace with your project's navigation)
  • :entity/* — Entity display names (replace with your entities)

Use the t function in views:

(require '[myapp.i18n.core :refer [t]])

(t :common/save)           ; => "Save" (en) or "Guardar" (es)
(t :common/save :en)       ; => "Save" (forced locale)
(t :validation/required {:field "Email"}) ; => "Email is required"

19. Authentication and Security

Default Users

After running lein database:

EmailPasswordLevel
user@example.comuserU
admin@example.comadminA
system@example.comsystemS

Change all passwords before deploying to production.

Authentication Features

  • Password hashing with buddy-hashers (bcrypt)
  • Secure cookie-based sessions
  • Login/logout built-in at /home/login and /home/logoff
  • All routes under proutes require an active session
  • Role-based access per entity via :rights

Creating New Users

When an admin creates a new user through the Users grid, the before-save hook in hooks/users.clj automatically assigns a default password equal to the user's username (email address), hashed with bcrypt. The user can log in immediately and must change their password via the Change Password option in the navbar dropdown.

When editing an existing user, if the password field is not submitted, the hook strips the key from params so the existing password hash is never overwritten.

ScenarioResult
New user, no passwordDefault password = username (email), bcrypt-hashed
Existing user, no password in formExisting password preserved unchanged
Any user, password value providedValue hashed with bcrypt before saving

Custom Middleware

Add middleware in src/myapp/core.clj:

(defn wrap-request-logging [handler]
  (fn [request]
    (println (:request-method request) (:uri request))
    (handler request)))

(defn app []
  (-> (routes open-routes proutes)
      wrap-request-logging
      wrap-session
      (wrap-defaults site-defaults)))

20. Themes

Set :theme in app-config.edn. Available Bootstrap themes:

default cerulean cosmo cyborg darkly flatly journal litera lumen lux materia minty morph pulse quartz sandstone simplex sketchy slate solar spacelab superhero united vapor yeti zephyr


21. Deployment

Build

lein uberjar

Run

java -jar target/uberjar/myapp-0.1.0-standalone.jar

Run on a Specific Port

java -jar target/uberjar/myapp-0.1.0-standalone.jar 9090

Set :port in app-config.edn or pass it as a command-line argument.


22. Publishing the Template

The template is published to Clojars at org.clojars.hector/webgen. To publish an updated version:

# Update version in project.clj
lein deploy clojars

23. Custom Dashboards and Reports

The entity system covers CRUD operations efficiently. It does not cover dashboards, aggregated reports, multi-step wizards, custom POS interfaces, or any page where the layout or data needs to differ fundamentally from a data grid.

For those cases, WebGen gets out of the way. You write a normal Clojure Ring handler using the same MVC directory pattern as src/myapp/handlers/home/. The framework wraps your output with the application layout, applies authentication middleware, and serves the page — nothing more.

When to Use a Custom Handler Instead of an Entity

NeedApproach
List, create, edit, delete a tableEntity EDN config
Aggregate totals, charts, KPIsCustom handler
Date-range filtered reportCustom handler
CSV / Excel exportCustom handler
Multi-step form or wizardCustom handler
Custom POS / kiosk screenCustom handler
Read-only summary joining many tablesCustom handler or entity with :list query

Directory Layout

Create a subdirectory under handlers/ for each logical section:

src/myapp/handlers/
  dashboard/
    controller.clj   ; Ring handlers — receives request, returns response
    model.clj        ; Database queries, aggregations, business logic
    view.clj         ; Hiccup HTML templates
  reports/
    controller.clj
    model.clj
    view.clj

Registering Routes

All protected pages go in src/myapp/routes/proutes.clj. The framework applies authentication middleware to every route declared here automatically.

(ns myapp.routes.proutes
  (:require [compojure.core :refer [defroutes GET POST]]
            [myapp.handlers.dashboard.controller :as dashboard]
            [myapp.handlers.reports.controller   :as reports]))

(defroutes proutes
  (GET  "/dashboard"              request (dashboard/main request))
  (GET  "/reports/sales"          request (reports/sales request))
  (GET  "/reports/sales/export"   request (reports/export-csv request))
  (POST "/reports/sales"          request (reports/sales request)))

Adding Items to the Menu

To make your custom pages appear in the navigation bar alongside the entity entries, edit src/myapp/menu.clj. Add top-level links to custom-nav-links and grouped dropdown entries to custom-dropdowns:

;; Top-level nav link — visible to admins and above
(def custom-nav-links
  [["/"          "Home"      nil  0]
   ["/dashboard" "Dashboard" "A"  5]])

;; Dropdown group with report links
(def custom-dropdowns
  {:Reports
   {:id      "navdropReports"
    :data-id "Reports"
    :label   "Reports"
    :order   40
    :items   [["/reports/sales" "Sales Report" "A" 10]]}})

Dashboard with KPI Cards and Chart Data

src/myapp/handlers/dashboard/model.clj

(ns myapp.handlers.dashboard.model
  (:require [myapp.models.crud :refer [db Query]]))

(defn kpi-summary
  "Aggregate totals for today, this month, and all time."
  []
  (let [today        (first (Query db ["SELECT
                                         COUNT(*)           AS ventas_hoy,
                                         COALESCE(SUM(total), 0) AS ingresos_hoy
                                       FROM ventas
                                       WHERE DATE(fecha) = DATE('now')
                                         AND estado = 'completada'"]))
        this-month   (first (Query db ["SELECT
                                         COUNT(*)           AS ventas_mes,
                                         COALESCE(SUM(total), 0) AS ingresos_mes
                                       FROM ventas
                                       WHERE strftime('%Y-%m', fecha) = strftime('%Y-%m', 'now')
                                         AND estado = 'completada'"]))
        total-prods  (first (Query db ["SELECT COUNT(*) AS total FROM productos"]))]
    (merge today this-month total-prods)))

(defn sales-by-day
  "Last 30 days of daily revenue — suitable for a chart."
  []
  (Query db ["SELECT
               DATE(fecha)         AS dia,
               COUNT(*)            AS ventas,
               COALESCE(SUM(total), 0) AS ingresos
             FROM ventas
             WHERE fecha >= DATE('now', '-30 days')
               AND estado = 'completada'
             GROUP BY DATE(fecha)
             ORDER BY dia"]))

(defn top-products
  "Top 10 products by quantity sold."
  [limit]
  (Query db ["SELECT
               pro.nombre,
               SUM(det.cantidad)            AS unidades,
               SUM(det.subtotal)            AS ingresos
             FROM ventas_detalle det
             JOIN productos pro ON det.producto_id = pro.id
             JOIN ventas    ven ON det.venta_id    = ven.id
             WHERE ven.estado = 'completada'
             GROUP BY pro.id
             ORDER BY unidades DESC
             LIMIT ?"
            limit]))

src/myapp/handlers/dashboard/view.clj

(ns myapp.handlers.dashboard.view
  (:require [clojure.data.json :as json]))

(defn- kpi-card [label value icon color]
  [:div.col-md-3
   [:div.card.border-0.shadow-sm
    [:div.card-body.d-flex.align-items-center.gap-3
     [:div {:class (str "fs-1 text-" color)}
      [:i {:class (str "bi " icon)}]]
     [:div
      [:div.text-muted.small label]
      [:div.fs-4.fw-bold value]]]]])

(defn- top-products-table [rows]
  [:table.table.table-sm.table-hover
   [:thead.table-light
    [:tr [:th "Producto"] [:th.text-end "Unidades"] [:th.text-end "Ingresos"]]]
   [:tbody
    (for [r rows]
      [:tr
       [:td (:nombre r)]
       [:td.text-end (:unidades r)]
       [:td.text-end (format "$%.2f" (double (:ingresos r 0)))]])]])

(defn dashboard-view [kpi chart-data top-prods]
  (list
   ;; KPI cards
   [:div.row.g-3.mb-4
    (kpi-card "Ventas hoy"       (:ventas_hoy   kpi 0) "bi-cart-check"   "primary")
    (kpi-card "Ingresos hoy"     (format "$%.2f" (double (:ingresos_hoy  kpi 0))) "bi-currency-dollar" "success")
    (kpi-card "Ventas este mes"  (:ventas_mes   kpi 0) "bi-calendar-month" "info")
    (kpi-card "Productos"        (:total        kpi 0) "bi-box-seam"     "warning")]

   ;; Revenue chart (Chart.js)
   [:div.row.g-3.mb-4
    [:div.col-lg-8
     [:div.card.border-0.shadow-sm
      [:div.card-header.bg-transparent.fw-bold "Ingresos diarios (últimos 30 días)"]
      [:div.card-body
       [:canvas#sales-chart {:height "120"}]]]]
    [:div.col-lg-4
     [:div.card.border-0.shadow-sm
      [:div.card-header.bg-transparent.fw-bold "Top productos"]
      [:div.card-body (top-products-table top-prods)]]]]

   ;; Embed chart data for JavaScript
   [:script
    (str "var CHART_DATA = " (json/write-str chart-data) ";")]
   [:script {:src "/vendor/chart.min.js"}]
   [:script
    "
    (function() {
      var labels  = CHART_DATA.map(function(d){ return d.dia; });
      var valores = CHART_DATA.map(function(d){ return d.ingresos; });
      new Chart(document.getElementById('sales-chart'), {
        type: 'bar',
        data: {
          labels: labels,
          datasets: [{ label: 'Ingresos', data: valores,
                       backgroundColor: 'rgba(13,110,253,0.6)' }]
        },
        options: { responsive: true, plugins: { legend: { display: false } } }
      });
    })();
    "]))

src/myapp/handlers/dashboard/controller.clj

(ns myapp.handlers.dashboard.controller
  (:require [myapp.handlers.dashboard.model :as model]
            [myapp.handlers.dashboard.view  :as view]
            [myapp.layout                   :refer [application]]
            [myapp.models.util              :refer [get-session-id]]))

(defn main [request]
  (let [kpi        (model/kpi-summary)
        chart-data (model/sales-by-day)
        top-prods  (model/top-products 10)
        content    (view/dashboard-view kpi chart-data top-prods)]
    (application request "Dashboard" (get-session-id request) nil content)))

Date-Range Sales Report with CSV Export

src/myapp/handlers/reports/model.clj

(ns myapp.handlers.reports.model
  (:require [myapp.models.crud :refer [db Query]]))

(defn sales-report
  "Fetch sales detail rows between two dates (inclusive)."
  [from to]
  (Query db ["SELECT
               ven.id          AS venta_id,
               ven.fecha,
               ven.total,
               ven.pago,
               ven.cambio,
               usr.username    AS cajero
             FROM ventas ven
             LEFT JOIN users usr ON ven.usuario_id = usr.id
             WHERE DATE(ven.fecha) BETWEEN ? AND ?
               AND ven.estado = 'completada'
             ORDER BY ven.fecha DESC"
            from to]))

(defn report-totals [rows]
  {:count   (count rows)
   :total   (reduce + 0 (map :total rows))
   :promedio (if (seq rows)
               (/ (reduce + 0 (map :total rows)) (count rows))
               0)})

src/myapp/handlers/reports/view.clj

(ns myapp.handlers.reports.view
  (:require [ring.util.anti-forgery :refer [anti-forgery-field]]))

(defn- filter-form [from to]
  [:form.row.g-2.align-items-end.mb-4
   {:method "GET" :action "/reports/sales"}
   [:div.col-md-3
    [:label.form-label "Desde"]
    [:input.form-control {:type "date" :name "from" :value from}]]
   [:div.col-md-3
    [:label.form-label "Hasta"]
    [:input.form-control {:type "date" :name "to" :value to}]]
   [:div.col-md-2
    [:button.btn.btn-primary.w-100 {:type "submit"} "Filtrar"]]
   [:div.col-md-2
    [:a.btn.btn-outline-secondary.w-100
     {:href (str "/reports/sales/export?from=" from "&to=" to)}
     [:i.bi.bi-download.me-1] "CSV"]]])

(defn- totals-bar [totals]
  [:div.row.g-2.mb-3
   [:div.col-md-3
    [:div.card.text-bg-light
     [:div.card-body.text-center
      [:div.text-muted.small "Ventas"]
      [:div.fw-bold.fs-5 (:count totals)]]]]
   [:div.col-md-3
    [:div.card.text-bg-light
     [:div.card-body.text-center
      [:div.text-muted.small "Total"]
      [:div.fw-bold.fs-5 (format "$%.2f" (double (:total totals 0)))]]]]
   [:div.col-md-3
    [:div.card.text-bg-light
     [:div.card-body.text-center
      [:div.text-muted.small "Promedio"]
      [:div.fw-bold.fs-5 (format "$%.2f" (double (:promedio totals 0)))]]]]])

(defn- results-table [rows]
  [:table.table.table-striped.table-sm
   [:thead.table-dark
    [:tr
     [:th "#"] [:th "Fecha"] [:th "Cajero"] [:th.text-end "Total"]
     [:th.text-end "Pago"] [:th.text-end "Cambio"]]]
   [:tbody
    (if (seq rows)
      (for [r rows]
        [:tr
         [:td (:venta_id r)]
         [:td (:fecha r)]
         [:td (:cajero r)]
         [:td.text-end (format "$%.2f" (double (:total  r 0)))]
         [:td.text-end (format "$%.2f" (double (:pago   r 0)))]
         [:td.text-end (format "$%.2f" (double (:cambio r 0)))]])
      [:tr [:td.text-center {:colspan 6} "No hay ventas en el período seleccionado."]])]])

(defn sales-view [from to rows totals]
  (list
   [:h4.mb-3 "Reporte de Ventas"]
   (filter-form from to)
   (totals-bar totals)
   [:div.card.border-0.shadow-sm
    [:div.card-body (results-table rows)]]))

src/myapp/handlers/reports/controller.clj

(ns myapp.handlers.reports.controller
  (:require [myapp.handlers.reports.model :as model]
            [myapp.handlers.reports.view  :as view]
            [myapp.layout                 :refer [application]]
            [myapp.models.util            :refer [get-session-id]]
            [clojure.string               :as str]))

(defn- today [] (str (java.time.LocalDate/now)))
(defn- first-of-month []
  (str (.withDayOfMonth (java.time.LocalDate/now) 1)))

(defn sales [request]
  (let [from   (get-in request [:params :from] (first-of-month))
        to     (get-in request [:params :to]   (today))
        rows   (model/sales-report from to)
        totals (model/report-totals rows)
        content (view/sales-view from to rows totals)]
    (application request "Sales Report" (get-session-id request) nil content)))

(defn export-csv [request]
  (let [from  (get-in request [:params :from] (first-of-month))
        to    (get-in request [:params :to]   (today))
        rows  (model/sales-report from to)
        header "id,fecha,cajero,total,pago,cambio\n"
        lines  (map (fn [r]
                      (str/join ","
                        [(:venta_id r) (:fecha r) (:cajero r)
                         (:total r 0) (:pago r 0) (:cambio r 0)]))
                    rows)
        csv   (str header (str/join "\n" lines))
        filename (str "ventas-" from "-" to ".csv")]
    {:status  200
     :headers {"Content-Type"        "text/csv; charset=utf-8"
               "Content-Disposition" (str "attachment; filename=\"" filename "\"")}
     :body    csv}))

How CSV export works: The export-csv handler runs the same query as the report page but returns a plain-text response with Content-Type: text/csv and a Content-Disposition: attachment header. The browser treats this as a file download automatically — no JavaScript or library needed.


JSON Data API for Charts

When you need charts that refresh without reloading the page, expose the aggregated data as a JSON endpoint:

;; In proutes.clj
(GET "/api/reports/sales-by-day" request (reports/api-sales-by-day request))

;; In controller.clj
(defn api-sales-by-day [request]
  (let [from (get-in request [:params :from] (first-of-month))
        to   (get-in request [:params :to]   (today))
        data (model/sales-by-day-range from to)]
    {:status  200
     :headers {"Content-Type" "application/json"}
     :body    (json/write-str {:ok true :data data})}))

The front-end calls this endpoint with fetch() and renders the data into a Chart.js or any other chart library.


Working Example: Contactos Summary Dashboard

This example uses the three tables that ship with the default template (contactos, cars, siblings) and shows the exact files you need to create.

What it renders: a read-only page at /contactos-dashboard that shows total contacts, total cars, total siblings, and a per-contact breakdown table.


src/myapp/handlers/contactos_dashboard/model.clj

(ns myapp.handlers.contactos-dashboard.model
  (:require [myapp.models.crud :refer [db Query]]))

(defn summary
  "Aggregate totals across the three demo tables."
  []
  (let [totals (first (Query db ["SELECT
                                    (SELECT COUNT(*) FROM contactos) AS total_contactos,
                                    (SELECT COUNT(*) FROM cars)      AS total_cars,
                                    (SELECT COUNT(*) FROM siblings)  AS total_siblings"]))]
    totals))

(defn per-contact
  "One row per contact with their car and sibling counts."
  []
  (Query db ["SELECT
               con.id,
               con.name,
               con.email,
               COUNT(DISTINCT car.id) AS cars,
               COUNT(DISTINCT sib.id) AS siblings
             FROM contactos con
             LEFT JOIN cars     car ON car.contacto_id = con.id
             LEFT JOIN siblings sib ON sib.contacto_id = con.id
             GROUP BY con.id
             ORDER BY con.name"]))

src/myapp/handlers/contactos_dashboard/view.clj

(ns myapp.handlers.contactos-dashboard.view)

(defn- kpi-card [label value color icon]
  [:div.col-md-4
   [:div.card.border-0.shadow-sm.mb-3
    [:div.card-body.d-flex.align-items-center.gap-3
     [:div {:class (str "fs-1 text-" color)}
      [:i {:class (str "bi " icon)}]]
     [:div
      [:div.text-muted.small label]
      [:div.fs-4.fw-bold value]]]]])

(defn- breakdown-table [rows]
  [:table.table.table-striped.table-hover.table-sm
   [:thead.table-dark
    [:tr
     [:th "Name"] [:th "Email"]
     [:th.text-center "Cars"] [:th.text-center "Siblings"]]]
   [:tbody
    (if (seq rows)
      (for [row rows]
        [:tr
         [:td (:name row)]
         [:td (:email row)]
         [:td.text-center (:cars row)]
         [:td.text-center (:siblings row)]])
      [:tr [:td.text-center {:colspan 4} "No contacts found."]])]])

(defn dashboard-view [summary rows]
  (list
   [:h4.mb-4 "Contactos Summary"]
   [:div.row.g-3.mb-4
    (kpi-card "Total Contacts" (:total_contactos summary 0) "primary"  "bi-people-fill")
    (kpi-card "Total Cars"     (:total_cars      summary 0) "success"  "bi-car-front-fill")
    (kpi-card "Total Siblings" (:total_siblings  summary 0) "warning"  "bi-person-lines-fill")]
   [:div.card.border-0.shadow-sm
    [:div.card-header.bg-transparent.fw-bold "Per-Contact Breakdown"]
    [:div.card-body (breakdown-table rows)]]))

src/myapp/handlers/contactos_dashboard/controller.clj

(ns myapp.handlers.contactos-dashboard.controller
  (:require [myapp.handlers.contactos-dashboard.model :as model]
            [myapp.handlers.contactos-dashboard.view  :as view]
            [myapp.layout                              :refer [application]]
            [myapp.models.util                         :refer [get-session-id]]))

(defn main [request]
  (let [summary (model/summary)
        rows    (model/per-contact)
        content (view/dashboard-view summary rows)]
    (application request "Contactos Dashboard" (get-session-id request) nil content)))

Register the route in src/myapp/routes/proutes.clj:

(ns myapp.routes.proutes
  (:require [compojure.core :refer [defroutes GET]]
            [myapp.handlers.contactos-dashboard.controller :as contactos-dash]))

(defroutes proutes
  (GET "/contactos-dashboard" request (contactos-dash/main request)))

Add a menu entry in src/myapp/menu.clj:

(def custom-nav-links
  [["/contactos-dashboard" "Contactos Dashboard" "U" 20]])

The page is now available at http://localhost:3000/contactos-dashboard for any logged-in user.


Summary: The 20% Pattern

Entity EDN config  -->  80%  -->  CRUD grids, forms, FK lookups, subgrids
Custom handler     -->  20%  -->  Dashboards, reports, exports, custom screens

Both approaches live in the same project and share the same database connection, layout, session, i18n, and middleware. The entity system accelerates the repetitive parts; the custom handler gives you full control for the parts that make your application unique.

Users create projects directly from Clojars without cloning this repository:

lein new org.clojars.hector/webgen myapp

License

MIT License. See LICENSE for details.

Resources

  • Clojure

  • Leiningen

  • Ring

  • Compojure

  • Hiccup

  • Bootstrap 5

  • Bootstrap Icons

  • DataTables

  • Ragtime

  • Parameter-Driven: Define entities in EDN files - no code generation needed

  • Hot Reload: Change entity configs and hooks, refresh browser - no server restart

  • Database-Agnostic: MySQL, PostgreSQL, SQLite with automatic migrations

  • Auto-Scaffolding: lein scaffold products creates entity config and hooks from the live database table (migration must be applied first)

  • Built for Enterprise: MRP, Accounting, Inventory, POS-capable

  • Hook System: Customize behavior without modifying core framework code

Performance: Quick Start

Installation

# Create new project from Clojars
lein new org.clojars.hector/webgen myapp
cd myapp

Note: The template is published to Clojars, so no manual installation needed!

First Run

# 1. Configure database
# Edit `resources/config/app-config.edn` with your database credentials
# Default uses SQLite - just update passwords for MySQL/PostgreSQL if needed
nano resources/config/app-config.edn

# 2. Run migrations
lein migrate

# 3. Seed database with default users
lein database

# 4. Start server
lein with-profile dev run
# Visit: http://localhost:3000
# Default credentials: admin@example.com / admin

Included Example Entities

The generated project includes four pre-configured entities to help you get started:

  • Users - User management with authentication and roles
  • Contactos - Main entity demonstrating file uploads (images)
  • Cars - Subgrid example (child of Contactos) with file uploads
  • Siblings - Another subgrid example (child of Contactos)

These demonstrate:

  • Master-detail relationships (subgrids in modal windows)
  • File upload handling via hooks (before-save/after-load)
  • Menu organization and visibility (Cars and Siblings hidden from main menu)
  • Form validation and various field types

You can explore these examples or remove them to start fresh.

Create Additional Entities

# 1. Create migration files manually in resources/migrations/
#    e.g. 002-products.sqlite.up.sql / 002-products.sqlite.down.sql
# 2. Write CREATE TABLE SQL in the .up file
lein migrate                         # Apply the migration

# 2. Scaffold the entity from the live table
lein scaffold products

This creates:

  • resources/entities/products.edn - Entity configuration
  • src/myapp/hooks/products.clj - Hook file for customization

Note: Scaffold reads the existing database table. The migration must be applied before running lein scaffold.

Important: What's Different?

Traditional Code Generation (v1)

lein grid users → Generates 3 files (225 lines)
Customize → Regenerate → LOSE CHANGES

Parameter-Driven (WebGen)

Create users.edn (80 lines) → Refresh browser → Done
Modify config → Never lose changes

Features: Key Features

FeatureDescription
No Code GenerationPure configuration-driven - edit EDN files, not generated code
Hot ReloadChange configs/hooks → refresh browser (no restart needed)
Auto-Scaffoldinglein scaffold entity generates entity config and hooks from the live table (apply migration first)
Multi-DatabaseMySQL, PostgreSQL, SQLite with vendor-specific migrations
SubgridsMaster-detail relationships with modal interfaces
File UploadsAutomatic handling via hooks (before-save/after-load)
Hook SystemCustomize without touching framework code
Auto-MenuMenu generated from entity configs
Modern UIBootstrap 5 + DataTables

Entity Configuration

Entity configs define everything about a CRUD interface. Located in resources/entities/*.edn:

{:entity     :products               ; Unique keyword identifier
 :table      "products"              ; Database table name
 :title      "Products"              ; Page title / menu display text
 :connection :default                ; DB connection key from app-config.edn
 :rights     ["U" "A" "S"]          ; Access control: User / Admin / System
 :menu-category :catalog             ; Menu grouping (any keyword)
 :menu-hidden? false                 ; Hide from menu (for subgrids)

 ;; Field definitions
 :fields
 [{: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 :price
    :label "Price"
    :type :decimal
    :min 0
    :step 0.01
    :placeholder "0.00"}            ; decimal input
  
  {:id :category 
   :label "Category" 
   :type :select                     ; Dropdown
   :options [{:value "electronics" :label "Electronics"}
             {:value "clothing" :label "Clothing"}
             {:value "food" :label "Food"}]}

  {:id :categories_id
   :label "Category"
   :type :fk
   :fk :categories
   :fk-field [:name]  ; you can have more than one [:name :email :phone]
   :required? true}                   ; FK field, does not require fk in db

   {:id :categories_id
    :label "Category"
    :type :select
    :options :myapp.models.lookups/get-categories} ; Dropdown with Database Values

  {:id :total                  
   :type :computed
   :compute-fn :myapp.hooks.products/calculate-total  ; computed

  {:id :price
   :label "Price"
   :type :decimal
   :min 0
   :step 0.01
   :placeholder "0.00"
   :required? true
   :validation :myapp.validators.products/positive-price?} ; validator

  
  {: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
             :foreign-key :product_id    ; Foreign key in child table
             :title       "Product Reviews"
             :icon        "bi bi-list"}]}

Supported Field Types

TypeDescriptionExampleOptions
:textSingle-line text inputName, SKU, titleplaceholder, required
:textareaMulti-line text inputDescription, notesrows, placeholder
:numberNumeric integer inputQuantity, agemin, max, placeholder
:decimalDecimal/float inputPrice, percentagemin, max, step, placeholder
:emailEmail input with validationEmail addressplaceholder, required
:passwordPassword input (masked)Password fieldplaceholder, required
:dateDate pickerBirth date, expirymin, max
:datetimeDate and time pickerCreated timestampmin, max
:selectDropdown selectCategory, statusoptions (array of {:value :label})
:radioRadio button groupStatus, typeoptions (array with :id, :label, :value)
:checkboxSingle checkboxActive, featuredvalue (value sent when checked; "" sent when unchecked)
:fileFile uploadImage, PDFHandled via hooks
:hiddenHidden fieldID, foreign keysvalue
:computedCalculated/display onlyTotal, full nameRead-only, computed via hooks

Menu Organization

Entities are automatically grouped in menus by :menu-category:

  • :admin → Administration
  • :catalog → Catalog
  • :sales → Sales
  • :reports → Reports
  • :system → System

Or hide from menu with :menu-hidden? true (useful for subgrids).

Hook System

Hooks let you customize behavior without modifying core code. All hooks are optional.

Available Hooks

(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 params]
  ;; Called after loading data from database
  ;; Transform display data (e.g., format dates, create links)
  ;; rows = [{:id 1 :name "Product" :image "photo.jpg"} ...]
  (mapv #(assoc % :image (str "<img src='/uploads/" (:image %) "'>")) rows))

(defn before-save [params]
  ;; Called before saving to database
  ;; Validate, transform, handle file uploads
  ;; params = {:id 1 :name "Product" :image "photo.jpg"}
  ;; Return {:errors {:field "message"}} to abort the save
  (if (contains? params :image)
    (assoc params :file (:image params))  ; Trigger file upload
    params))

(defn after-save [entity-id params]
  ;; Called after saving to database
  ;; Send notifications, update related records
  ;; entity-id = saved record ID, params = form data
  (println "Saved product:" entity-id)
  {:success true})

(defn before-delete [entity-id]
  ;; Called before deleting record
  ;; Validate deletion, check constraints
  ;; entity-id = 123
  ;; Return {:errors {:general "message"}} to prevent deletion
  (println "Deleting product:" entity-id)
  {:success true})

(defn after-delete [entity-id]
  ;; Called after deleting record
  ;; Clean up files, update related records
  ;; entity-id = 123
  (println "Deleted product:" entity-id)
  {:success true})

Hook Registration

Register hooks in entity config resources/entities/products.edn:

{:entity :products
 :table  "products"
 :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}}

Common Use Cases

File Uploads:

(defn before-save [params]
  (if (contains? params :imagen)
    (assoc params :file (:imagen params))  ; Framework handles upload
    params))

(defn after-load [rows params]
  (mapv #(assoc % :imagen (image-link (:imagen %))) rows))

Data Validation:

(defn before-save [params]
  (if (neg? (or (:price params) 0))
    {:errors {:price "Price cannot be negative"}}
    params))

Automatic Timestamps:

(defn before-save [params]
  (assoc params :updated_at (java.time.LocalDateTime/now)))

Database Database Support

WebGen generates vendor-specific migrations automatically.

Multi-Database Architecture

lein scaffold products

Generates the entity config and hooks file from the live database table. To add a new table, create the migration SQL files manually, apply them, then scaffold:

# Create resources/migrations/002-products.sqlite.up.sql (and .down.sql)
# Write the CREATE TABLE SQL, then:
lein migrate                          # Apply migration
lein scaffold products                # Generate entity from live table

Switch databases without code changes - just update config!

Configuration

Edit resources/config/app-config.edn:

{:connections
 {;; --- Mysql database ---
  :mysql {:db-type   "mysql"                                 ;; "mysql", "postgresql", "sqlite", etc.
          :db-class  "com.mysql.cj.jdbc.Driver"              ;; JDBC driver class
          :db-name   "//localhost:3306/your_dbname"          ;; JDBC subname (host:port/db)
          :db-user   "root"
          :db-pwd    "your_password"}

  ;; --- Local SQLite database ---
  :sqlite {:db-type   "sqlite"
           :db-class  "org.sqlite.JDBC"
           :db-name   "db/your_dbname.sqlite"}               ;; No user/pwd needed for SQLite

  ;; --- PostgreSQL database ---
  :postgres {:db-type   "postgresql"
             :db-class  "org.postgresql.Driver"
             :db-name   "//localhost:5432/your_dbname"
             :db-user   "root"
             :db-pwd    "your_password"}

  ;; --- Default connection used by the app ---
  :main :sqlite ; Used for migrations (SQLite by default for easy prototyping)
  :default :sqlite ; Used for application (switch to :mysql or :postgres for production)
  :db :mysql
  :pg :postgres
  :localdb :sqlite}

 ;; --- Other global app settings ---
 :uploads      "./uploads/your_dbname/"    ;; Path for file uploads
 :site-name    "your_site_name"            ;; App/site name
 :company-name "your_company_name"         ;; Company name
 :port         3000                        ;; App port
 :tz           "US/Pacific"                ;; Timezone
 :base-url     "http://0.0.0.0:3000/"      ;; Base URL
 :img-url      "https://0.0.0.0/uploads/"  ;; Image base URL
 :path         "/uploads/"                 ;; Uploads path (for web)
 :max-upload-mb 5                            ;; Optional: max image upload size in MB
 :allowed-image-exts ["jpg" "jpeg" "png" "gif" "bmp" "webp"] ;; Optional: allowed image extensions
 ;; --- Theme selection ---
 :theme "sketchy" ;; Options: "default" (Bootstrap), "cerulean", "slate", "minty", "lux", "cyborg", "sandstone", "superhero", "flatly", "yeti"
 ;; Optional email config
 :email-host   "smtp.example.com"
 :email-user   "user@example.com"
 :email-pwd    "emailpassword"}

Migration Commands

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)

Documentation: Documentation

Generated projects include a README.md with quick-start instructions and this reference. For full framework documentation, see the repository README.

Tools: Common Commands

# Project Creation
lein new org.clojars.hector/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 3000)
                                     # Auto-reloads on config/hook changes
lein compile                         # Compile project

# Scaffolding (migration must be applied first)
# Create migration SQL files in resources/migrations/ manually
lein migrate                         # Apply the migration
lein scaffold products               # Generate entity config and hooks from live table
                                     # - resources/entities/products.edn
                                     # - src/myapp/hooks/products.clj

# Testing
lein test                            # Run tests

# Production
lein uberjar                         # Build standalone JAR
java -jar target/uberjar/myapp-0.1.0-standalone.jar  # Run production server

Auto-Reload Feature

The dev server watches for changes and reloads automatically:

  • Entity configs (resources/entities/*.edn) - Reloads every 2 seconds
  • Hook files (src/myapp/hooks/*.clj) - Reloads on file change
  • No server restart needed - Just refresh your browser!

Security

Built-in authentication and role-based access control.

Default Users

After running lein database:

UsernamePasswordRoleLevelAccess
user@example.comuserRegular UserUStandard access
admin@example.comadminAdministratorAFull access
system@example.comsystemSystemSSystem-level access

WARNING Change default passwords in production!

Login with: admin@example.com / admin

Authentication Features

  • Password Hashing - buddy-hashers with bcrypt
  • Session Management - Secure cookie-based sessions
  • Login/Logout - Built-in authentication pages
  • Protected Routes - Middleware guards private pages
  • Role-Based Access - Control menu visibility by role

Customizing Access

Edit src/myapp/core.clj to modify authentication logic:

(defn wrap-login [handler]
  ;; Customize authentication check
  ;; Redirect unauthorized users
  ...)

Configure in entity configs:

{:entity :admin_only_entity
 :table  "admin_only_entity"
 :menu-category :admin
 :rights ["A" "S"]}              ; Restrict to admins and system only

Design: Advanced Customization (The 20%)

While 80% of your application is configuration-driven, the framework provides full control for the remaining 20% through manual customization.

Manual Menu Customization

Edit src/myapp/menu.clj to add custom menu items that don't come from entities.

Custom Navigation Links

(def custom-nav-links
  [["/"          "Home"      nil  0]
   ["/dashboard" "Dashboard" "U" 10]
   ["/reports"   "Reports"   "U" 20]
   ["/analytics" "Analytics" "A" 30]])

Custom Dropdown Menus

(def custom-dropdowns
  {:Reports {:id      "navdropReports"
             :data-id "Reports"
             :label   "Reports"
             :order   40
             :items   [["/reports/sales"     "Sales"     "U" 10]
                       ["/reports/inventory" "Inventory" "U" 20]
                       ["/reports/customers" "Customers" "U" 30]]}

   :Tools   {:id      "navdropTools"
             :data-id "Tools"
             :label   "Tools"
             :order   50
             :items   [["/tools/import" "Import Data" "A" 10]
                       ["/tools/export" "Export Data" "A" 20]
                       ["/tools/backup" "Backup"      "A" 30]]}})

Result: get-menu-config in menu.clj automatically merges custom-nav-links and custom-dropdowns with the auto-generated entity menus, sorted by :order. You only need to update those two vars — do not redefine get-menu-config.


Routes Custom Routes

WebGen separates routes into two categories:

1. Open Routes (Public Access)

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)))

2. Protected Routes (Authenticated Access)

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.


Advanced Hook Patterns

Hooks provide deep customization without modifying framework code.

Complex Data Transformations

(ns myapp.hooks.orders)

(defn after-load [rows params]
  ;; Calculate derived fields
  (mapv (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>"))

Multi-File Upload Handling

(ns myapp.hooks.products)

(defn before-save [params]
  ;; Handle multiple file uploads
  (cond-> params
    ;; Main product image
    (contains? params :image)
    (assoc :file (:image params))

    ;; Product thumbnail
    (contains? params :thumbnail)
    (assoc :file_thumb (:thumbnail params))

    ;; Product PDF datasheet
    (contains? params :datasheet)
    (assoc :file_pdf (:datasheet params))))

(defn after-save [entity-id params]
  ;; Generate thumbnails after save
  (when (:image params)
    (generate-thumbnail (:image params)))

  ;; Update search index
  (update-search-index params)

  ;; Notify warehouse
  (notify-inventory-system params)

  {:success true})

Dynamic Query Filtering

(ns myapp.hooks.orders)

(defn before-load [params]
  ;; Add user-specific filters
  (let [user  (:user params)
        level (:level user)]
    (cond
      ;; System and Admin see everything
      (contains? #{"S" "A"} level)
      params

      ;; Regular users see only their own records
      (= level "U")
      (assoc-in params [:filters :user_id] (:id user))

      ;; Default: no access
      :else
      (assoc params :filters {:id -1}))))  ; Returns no results

Complex Validation

(ns myapp.hooks.invoices)

(defn before-save [params]
  ;; Business rule validation — return {:errors {...}} to abort
  (let [errors (cond-> {}
                 (< (or (:total params) 0) 0)
                 (assoc :total "Invoice total cannot be negative")

                 (empty? (:customer_id params))
                 (assoc :customer_id "Customer is required"))]
    (if (seq errors)
      {:errors errors}
      ;; Auto-calculate fields before saving
      (let [items    (fetch-invoice-items (:id params))
            subtotal (reduce + 0 (map :total items))
            tax      (* subtotal (or (:tax_rate params) 0))
            total    (+ subtotal tax)]
        (assoc params
               :subtotal   subtotal
               :tax        tax
               :total      total
               :updated_at (java.time.LocalDateTime/now))))))

Cascade Operations

(ns myapp.hooks.customers)

(defn after-delete [entity-id]
  ;; Cascade delete related records
  (delete-customer-addresses entity-id)
  (delete-customer-orders entity-id)
  (delete-customer-notes entity-id)

  ;; Update analytics
  (update-customer-count)

  ;; Audit log
  (log-customer-deletion entity-id)

  {:success true})

Architecture Custom Handlers (MVC Pattern)

Create custom handlers for non-CRUD functionality.

Directory Structure

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

Example: Custom Dashboard

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)]])]]]])))

Setup: Direct Database Access

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 cus.*, SUM(ord.total) as total_spent
     FROM customers cus
     JOIN orders ord ON cus.id = ord.customer_id
     GROUP BY cus.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]))

Configuration Middleware Customization

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)))

Custom Layout Components

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"}]]))

API Integration

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)))

Package: Publishing to Clojars

The template is already published to Clojars at org.clojars.hector/webgen.

For template maintainers to publish updates:

cd webgen  # Your cloned repository
# Update version in project.clj
lein deploy clojars

Users can now install directly:

lein new org.clojars.hector/webgen myapp  # No git clone needed!

Note: License

MIT License - see LICENSE file for details.

Contributing

Issues and pull requests welcome! This is an active project used in production environments.

Resources

Can you improve this documentation?Edit on GitHub

cljdoc builds & hosts documentation for Clojure/Script libraries

Keyboard shortcuts
Ctrl+kJump to recent docs
Move to previous article
Move to next article
Ctrl+/Jump to the search field
× close