| name | dmvcframework-webapp |
| description | Use when building a server-side web application with DelphiMVCFramework โ HTML pages and HTMX fragments rendered by TemplatePro. Covers view engine setup, template inheritance and blocks, ViewData and its ownership rules, fragments, custom filters, cookie/JWT login, static files, and the HTMX request/response helpers on the Delphi side. Triggers on "TemplatePro", "RenderView", "ViewData", "baselayout", "template inheritance", "HTML fragment", "web controller", "HTMX", "hx-get", "server-side view", "web application", "cookie auth", "static files". |
DMVCFramework โ Server-Side Views with TemplatePro + HTMX
Reference project: webapplication_with_htmx wizard template.
TemplatePro repo: https://github.com/danieleteti/templatepro
HTMX docs: https://htmx.org/docs/
REQUIRED COMPANION SKILL: dmvcframework โ covers the REST/ActiveRecord/middleware
layer that underpins every web application. If the user asks about controllers, entities,
JWT middleware configuration, or engine setup beyond what is in this skill, invoke dmvcframework.
COMPANION SKILL (on demand): htmx-skill โ indexes every page of the official htmx.org
documentation. For any attribute, trigger/swap modifier, event, header or extension beyond the few
patterns in Section 7, invoke it and read the linked page. Do not write HTMX from memory.
COMPANION SKILL (on demand): dmvcframework-ui โ the presentation layer the wizard generates:
Bootstrap 5.3, baselayout.html blocks, style.css brand tokens, dark mode via data-bs-theme, toasts.
Invoke it before writing markup or CSS.
REQUIRED REFERENCE โ dmvcframework-security. Any endpoint that accepts input from a client (body,
query string, header, cookie, upload, URL) must follow it: access control and IDOR, mass assignment,
SQL injection, XSS, CSRF, path traversal, uploads, security headers, JWT, secrets. Invoke it whenever you
write or review such an endpoint โ not only when the user says "security".
When in doubt about an API โ verify it, never guess
Never invent an identifier or answer from memory. If you need a signature this skill does not cover, ask the
user for the path to their DelphiMVCFramework checkout and read sources/ (and the matching samples/
project); failing that, read the official repository โ
sources ยท
samples.
If you still cannot verify it, say so. Where a skill and a sample disagree, the sample wins.
1. STOP โ this skill works inside a wizard-generated project
Never create the project from scratch. Never hand-write the .dpr or the base templates.
The wizard generates a running web application โ layout, static files, session, view engine, HTMX โ and your
job is to add pages to it.
Step 1 โ Detect the project in the current folder
The user is expected to have run the wizard and started the agent from inside the project folder. A web
wizard project has:
*.dpr Boot + RunServer (Indy Direct by default; a WebModule here means WebBroker/ISAPI/Apache โ also fine)
BootConfigU.pas dotEnv, LoggerPro, TemplatePro init
EngineConfigU.pas view engine, middleware/filters, UseExceptionHandler
Controllers.HomeU.pas web controller โ actions returning RenderView(...)
Controllers.APIU.pas JSON sidecar (if present)
TemplateProHelpersU.pas custom TemplatePro filters
ServicesU.pas DI registrations
bin/.env port, secrets, view settings
bin/templates/ baselayout.html + pages
bin/www/ static files (css/, favicon, manifest)
Read EngineConfigU.pas, the web controller and bin/templates/baselayout.html before writing anything.
Step 2 โ If there is no wizard project, stop and say so
Do not scaffold one. Tell the user:
This skill works on a project generated by the DMVCFramework IDE wizard, and I do not see one in this
folder. Please create it first:
Delphi IDE โ File โ New โ Other โ Delphi Projects โ DelphiMVCFramework โ New DMVCFramework Application
Pick Web Application (or Minimal API WebApp for lambda handlers). Accept the defaults โ the server
backend is Indy Direct. Compile and run it once and check the page opens in the browser. Then cd into
the project folder, start me there, and tell me what page to add.
Then wait. Do not generate project files in the meantime.
The host may be WebBroker (ISAPI, Apache) โ that is fine. Keep it, do not migrate it, do not suggest
migrating it. Everything above the host is identical on every backend. See dmvcframework,
reference/servers.md.
Step 3 โ Follow the scaffolding workflow for new pages
See Section 16 for the step-by-step workflow.
2. Architecture Overview
Browser
โ HTML pages + HTMX attributes
โ
โโ GET /web/... โ TWebController โ RenderView('path/template')
โ ViewData['key'] := value
โ
โโ GET /web/... โ TWebController โ RenderView(same template)
โ (HTMX partial) ViewData['ispage'] := not Request.IsHTMX
โ โ layout drops its own chrome
โ
โโ GET /api/... โ TAPIController โ OkResponse(StrDict(...))
(JSON sidecar)
Key invariants:
- All controller actions are functions, never procedures.
[MVCProduces(TMVCMediaType.TEXT_HTML)] is optional โ the samples omit it and set the default
content type once in the engine config (Config[TMVCConfigKey.DefaultContentType] := TMVCMediaType.TEXT_HTML)
or in OnBeforeAction. Use it only when one action must differ from the app default.
- Full-page actions call
Result := RenderView('folder/template').
- Fragment actions also call
RenderView. The same action serves the full page and the HTMX fragment;
the layout suppresses its own chrome:
ViewData['ispage'] := not Context.Request.IsHTMX; + {{if ispage}}<!DOCTYPE html>โฆ{{endif}} in
baselayout.html. Do not hand-build HTML strings.
procedure is allowed for redirects (Redirect('/people')), which produce no body.
- The JSON API sidecar (
/api) uses standard IMVCResponse factory methods.
3. Engine Configuration (EngineConfigU.pas)
procedure ConfigureEngine(AEngine: TMVCEngine);
var
LJWTClaimsSetup: TJWTClaimsSetup;
LWwwPath: string;
begin
LWwwPath := TPath.Combine(AppPath, 'www');
// Controllers
AEngine.AddController(TWebController);
AEngine.AddController(TAPIController);
// Server-Side View Engine
AEngine.SetViewEngine(TMVCTemplateProViewEngine);
// JWT claims setup (what goes into every issued token)
LJWTClaimsSetup := procedure(const JWT: TJWT)
begin
JWT.Claims.Issuer := dotEnv.Env('JWT_ISSUER', 'MyApp');
JWT.Claims.ExpirationTime := Now + OneHour;
JWT.Claims.NotBefore := Now - OneMinute * 5;
JWT.Claims.IssuedAt := Now;
end;
// Middleware โ order matters
AEngine.AddMiddleware(TMVCRedirectMiddleware.Create(['/'], '/web'));
AEngine.AddMiddleware(
UseJWTCookieAuthentication(
TAuthentication.Create,
LJWTClaimsSetup,
dotEnv.Env('JWT_SECRET', 'change-me'),
'/login', // POST credentials here
'/logout', // GET or POST to log out
[TJWTCheckableClaim.ExpirationTime,
TJWTCheckableClaim.NotBefore,
TJWTCheckableClaim.IssuedAt],
300 // Leeway seconds
)
.SetCookieSecure(dotEnv.Env('JWT_COOKIE_SECURE', False))
);
AEngine.AddMiddleware(
UseFileSessionMiddleware(0, False, TPath.Combine(AppPath, 'sessions')));
AEngine.AddMiddleware(TMVCStaticFilesMiddleware.Create('/static', LWwwPath));
AEngine.AddMiddleware(TMVCCompressionMiddleware.Create);
// Browser-friendly error pages: renders templates/error.html for clients that prefer HTML,
// leaves the JSON error body untouched for API clients. One call โ no handler to hand-write.
AEngine.UseExceptionHandler('error', 'MyApp');
end;
UseExceptionHandler(AErrorViewName = 'error'; AAppName = ''; AOptions = []) renders
<ViewPath>/error.<DefaultViewFileExtension> and exposes status, statustext, app_name,
dmvc_version, current_year to the template. error is empty unless you pass ehShowDetails in
AOptions โ which you want in development and almost certainly not in production, since it leaks the
exception message to the browser. Reach for SetExceptionHandler(proc) only when you need
behaviour this cannot express.
Required units for EngineConfigU.pas:
uses
TemplatePro,
MVCFramework.View.Renderers.TemplatePro,
MVCFramework.Middleware.JWT,
MVCFramework.Middleware.Session,
MVCFramework.Middleware.Redirect,
MVCFramework.Middleware.StaticFiles,
MVCFramework.Middleware.Compression,
MVCFramework.JWT,
System.IOUtils,
System.DateUtils;
4. Web Controller Pattern
unit Controllers.HomeU;
interface
uses MVCFramework, MVCFramework.Commons, MVCFramework.Serializer.Commons;
type
[MVCPath('/web')]
TWebController = class(TMVCController)
protected
procedure OnBeforeAction(Context: TWebContext;
const AActionName: string; var Handled: Boolean); override;
public
[MVCPath]
[MVCHTTPMethod([httpGET])]
[MVCProduces(TMVCMediaType.TEXT_HTML)]
function Index: String;
[MVCPath('/about')]
[MVCHTTPMethod([httpGET])]
[MVCProduces(TMVCMediaType.TEXT_HTML)]
function About: String;
// Fragment endpoint โ a partial TEMPLATE, rendered with RenderView (see section 9)
[MVCPath('/fragment/clock')]
[MVCHTTPMethod([httpGET])]
function GetClockFragment: String;
end;
implementation
uses System.SysUtils, System.DateUtils, MVCFramework.Logger;
procedure TWebController.OnBeforeAction(Context: TWebContext;
const AActionName: string; var Handled: Boolean);
begin
inherited;
// Set ViewData available to ALL views via baselayout.html
ViewData['app_name'] := 'MyApp';
ViewData['dmvc_version'] := DMVCFRAMEWORK_VERSION;
ViewData['current_year'] := YearOf(Now);
ViewData['page_id'] := AActionName.ToLower; // for aria-current="page"
end;
function TWebController.Index: String;
begin
ViewData['current_date'] := FormatDateTime('dddd, dd mmmm yyyy', Now);
Result := RenderView('home/index'); // renders templates/home/index.html
end;
function TWebController.About: String;
begin
Result := RenderView('about/index');
end;
function TWebController.GetClockFragment: String;
begin
ViewData['time'] := FormatDateTime('hh:nn:ss', Now);
ViewData['date'] := FormatDateTime('dddd, dd mmmm yyyy', Now);
Result := RenderView('home/_clock'); // templates/home/_clock.html โ no {{extends}}, so no chrome
end;
Passing objects and lists to templates
// Scalar values
ViewData['title'] := 'My Page';
ViewData['count'] := 42;
// Object โ template accesses properties with dot notation
ViewData['person'] := lPerson;
// List โ template iterates with {{for item in list}}
ViewData['people'] := lPeople; // TObjectList<T>
ViewData does NOT own what you put in it. TMVCViewDataObject is a
TObjectDictionary<string, TValue> constructed with no ownership flags
(MVCFramework.Commons.pas) โ nothing is freed for you. Free objects yourself, after the render:
function TWebController.Customers: String;
var
lCustomers: TObjectList<TCustomer>;
begin
lCustomers := GetCustomers;
try
ViewData['customers'] := lCustomers;
Result := RenderView('customers'); // render happens here, inside the try
finally
lCustomers.Free;
end;
end;
A TDataSet (e.g. a TFDMemTable) can be passed as ViewData the same way, and iterated with {{for}}.
Accessing session data and current user in a controller
// After UseJWTCookieAuthentication, the session is validated automatically.
// Access claims from the JWT token:
var lUsername := Context.LoggedUser.UserName;
var lRoles := Context.LoggedUser.Roles; // TList<string>
var lCustom := Context.LoggedUser.CustomData['key'];
5. TemplatePro Template Syntax
Output a variable
{{:variable_name}}
{{:person.FirstName}}
{{:person.Address.City}}
Template inheritance
Base layout (baselayout.html) defines named blocks:
<!DOCTYPE html>
<html>
<head>
<title>{{block "title"}}{{:app_name}}{{endblock}}</title>
</head>
<body>
<main>{{block "body"}}{{endblock}}</main>
{{block "scripts"}}{{endblock}}
</body>
</html>
Child template overrides blocks:
{{extends "../baselayout.html"}}
{{block "title"}}My Page{{endblock}}
{{block "body"}}
<h1>Hello from {{:app_name}}</h1>
{{endblock}}
{{extends "path"}} โ path is relative to the current template file.
error.html extends with {{extends "baselayout.html"}} (same folder).
- Subfolder templates use
{{extends "../baselayout.html"}}.
Conditionals
{{if page_id|eq,"index"}}aria-current="page"{{endif}}
{{if user_logged_in}}
<p>Welcome, {{:username}}!</p>
{{else}}
<p><a href="/login">Log in</a></p>
{{endif}}
Available comparison operators for |eq, |ne, |gt, |lt, |ge, |le:
{{if count|gt,0}}...{{endif}}
{{if role|eq,"admin"}}...{{endif}}
Loops
{{for person in people}}
<li>{{:person.FirstName}} {{:person.LastName}}</li>
{{endfor}}
{{# Loop index: it is a pseudo-property OF THE LOOP VARIABLE, and it is 1-based #}}
{{for item in items}}
<tr>
<td>{{:item.@@index}}</td>
<td>{{:item.Name}}</td>
</tr>
{{endfor}}
{{# ...so it composes with filters #}}
{{if item.@@index|lt,4}}<span class="badge">top</span>{{endif}}
Filters (built-in)
{{:name|uppercase}}
{{:name|lowercase}}
{{:name|capitalize}}
{{:name|rpad,10}} {{:code|lpad,3,"0"}}
{{:date|datetostr}} {{:ts|datetimetostr}} {{:ts|formatdatetime,"dd/mm/yyyy"}}
{{# Filters DMVCFramework adds on top of TemplatePro (MVCFramework.View.Renderers.TemplatePro) #}}
{{:obj|json}} {{:s|urlencode}} {{:list|count}} {{:name|fromquery}}
{{# Comparison filters work on dates too #}}
{{if cust.dob|ge,"2000/01/01"}}...{{endif}}
{{# Output is HTML-escaped by default. Suffix $ to emit raw HTML #}}
{{:myobj.rawhtml$}}
{{# Negation #}}
{{if !person}}<p>Not found</p>{{endif}}
{{:value|default,"N/A"}}
Custom filters (registered in TemplateProHelpersU.pas)
{{:value|MyHelper1}}
{{:value|MyHelper2}}
Comments
{{# This is a comment โ not rendered in HTML output #}}
Include (partial template)
{{include "partials/card.html"}}
6. Template File Structure
bin/templates/
baselayout.html โ base layout; all pages extend this
error.html โ browser error page (extends baselayout.html)
home/
index.html โ extends "../baselayout.html"
about/
index.html โ extends "../baselayout.html"
myfeature/
index.html โ extends "../baselayout.html"
_card.html โ reusable partial (prefix _ by convention)
The view path is configured in .env:
dmvc.view_path=templates
dmvc.default.view_file_extension=html
dmvc.view_cache=false
RenderView('home/index') resolves to templates/home/index.html.
7. HTMX โ the patterns this stack actually uses
HTMX is loaded in baselayout.html; child templates need no setup.
For any attribute, trigger modifier, swap mode, event or extension: invoke the htmx-skill and read
the linked htmx.org page. It indexes every page of the official docs. Do not write HTMX attributes
from memory โ inheritance rules and swap/trigger modifiers are easy to get subtly wrong. What follows is
only the handful of patterns that carry a Delphi-side implication.
The one pattern that shapes your controller: page or fragment, one action
The same action serves the full page and the HTMX fragment. The controller flags which one; the layout
suppresses its own chrome. You do not write a separate fragment endpoint, and you do not build
HTML strings.
function TWebController.Customers: String;
begin
ViewData['ispage'] := not Context.Request.IsHTMX; // uses MVCFramework.HTMX
ViewData['customers'] := lCustomers;
Result := RenderView('customers');
end;
{{# baselayout.html #}}
{{if ispage}}<!DOCTYPE html><html><head>โฆ</head><body><nav>โฆ</nav>{{endif}}
{{block "body"}}{{endblock}}
{{if ispage}}</body></html>{{endif}}
A plain browser navigation gets the whole document; an hx-get gets just the block. One template, one action.
Triggering the request
<button hx-get="/web/customers" hx-target="#list" hx-swap="innerHTML">Load</button>
<div hx-get="/web/stats" hx-trigger="load"></div>
<div hx-get="/web/stats" hx-trigger="every 5s"></div>
<form hx-post="/web/customers" hx-target="#result" hx-swap="outerHTML">โฆ</form>
<button hx-delete="/web/customers/1" hx-confirm="Delete this customer?" hx-target="closest tr"
hx-swap="outerHTML">Delete</button>
hx-put/hx-patch/hx-delete reach the matching [MVCHTTPMethod] action directly โ no method-override
hidden field, unlike a plain HTML form.
URL in the address bar
<a hx-get="/web/customers" hx-target="#main" hx-push-url="true">Customers</a>
Without hx-push-url, the fragment loads but the URL does not change and the page is not bookmarkable.
Loading indicator
htmx-request is added to the element for the duration of the request; style it in style.css:
.htmx-request { opacity: 0.5; transition: opacity 0.3s; }
Live updates: SSE, not polling
For server-pushed updates use Server-Sent Events. Do not hand-roll the stream in the action โ inherit
TMVCSSEController (see the dmvcframework skill, reference/sse.md) and consume it with the htmx SSE
extension (https://htmx.org/extensions/sse/).
8. HTMX response helpers (from the Delphi side)
uses MVCFramework.HTMX; โ it adds typed helpers to Context.Request / Context.Response.
Use them; do not hand-write HX-* header strings (a typo in a header name fails silently).
// full page reload, like window.location โ e.g. after login
Context.Response.HXSetRedirect('/web/dashboard');
// HTMX-aware navigation: no full reload, content swapped, history updated
Context.Response.HXSetLocation('/web/products');
// URL bar only
Context.Response.HXSetPushUrl('/web/items/' + ItemID);
Context.Response.HXSetReplaceUrl('/web/items/' + ItemID);
// client-side events (see the dmvcframework-ui skill for showToast)
Context.Response.HXTriggerClientEvent('refreshCart');
Context.Response.HXTriggerClientEvents(['refreshCart', 'updateBadge']);
// With a payload. It is serialized as a JSON *value*: a string arrives as evt.detail.value.
// Pass an object when you want named properties (evt.detail.FirstName, ...).
Context.Response.HXTriggerClientEvent('showMessage', 'Customer saved');
Context.Response.HXTriggerClientEvent('customerSaved', lCustomer);
// when it fires: etReceived (default), or after swap / after settle โ pass the TClientEventType
Context.Response.HXTriggerClientEvent('initDatePicker', etSettled);
// override the swap / target the server chose on the client
Context.Response.HXSetReswap(TSwapOption.soOuterHTML);
Context.Response.HXSetRetarget('#other-element');
// tell the client to refresh the page
Context.Response.HXSetPageRefresh;
On the request side: Context.Request.IsHTMX, HXIsBoosted, HXGetTarget, HXGetPrompt,
HXGetTriggeringEventAsJSON.
To do nothing (leave the DOM as it is), answer 204:
Context.Response.StatusCode := HTTP_STATUS.NoContent;
Result := '';
9. Fragments โ a template, never a string
A fragment is a template, rendered by the same view engine as a full page. Never concatenate HTML in
Delphi, and never call TTProCompiler.CompileAndRender yourself โ that bypasses the engine, its cache and
its filters, and it forces you to hand-escape every value.
Two ways to serve one, both RenderView:
a) Same action, page or fragment. Preferred: one URL, bookmarkable, works without JS.
function TWebController.Items: String;
var
lItems: TObjectList<TItem>;
begin
lItems := FItemService.GetAll;
try
ViewData['ispage'] := not Context.Request.IsHTMX;
ViewData['items'] := lItems;
Result := RenderView('items/index');
finally
lItems.Free; // ViewData owns nothing
end;
end;
b) A dedicated fragment action, when the fragment has no page of its own (a row after an edit, a
search-results list). Point it at a partial template and skip the layout:
[MVCPath('/fragment/item-list')]
[MVCHTTPMethod([httpGET])]
function TWebController.GetItemList: String;
var
lItems: TObjectList<TItem>;
begin
lItems := FItemService.GetAll;
try
ViewData['items'] := lItems;
Result := RenderView('items/_list'); // a template with no {{extends}} โ no chrome
finally
lItems.Free;
end;
end;
{{# templates/items/_list.html โ the fragment, and reusable via {{include}} from the page #}}
<ul id="item-list">
{{for item in items}}
<li id="item-{{:item.ID}}">
{{:item.Name}}
<a hx-delete="/web/items/{{:item.ID}}" hx-target="#item-{{:item.ID}}"
hx-swap="outerHTML" hx-confirm="Delete?">[x]</a>
</li>
{{endfor}}
</ul>
TemplatePro escapes {{:item.Name}} for you โ there is no HTMLEscape call to forget. The same partial
can be {{include}}d by the full page, so page and fragment never drift apart.
10. Cookie JWT Authentication
AuthenticationU.pas โ the three handler methods
type
TAuthentication = class(TInterfacedObject, IMVCAuthenticationHandler)
protected
procedure OnRequest(
const AContext: TWebContext;
const ControllerQualifiedClassName, ActionName: string;
var AuthenticationRequired: Boolean);
procedure OnAuthentication(
const AContext: TWebContext;
const UserName, Password: string;
UserRoles: TList<string>;
var IsValid: Boolean;
const SessionData: TSessionData);
procedure OnAuthorization(
const AContext: TWebContext;
UserRoles: TList<string>;
const ControllerQualifiedClassName, ActionName: string;
var IsAuthorized: Boolean);
end;
OnRequest โ which routes need auth
procedure TAuthentication.OnRequest(
const AContext: TWebContext;
const ControllerQualifiedClassName, ActionName: string;
var AuthenticationRequired: Boolean);
begin
// Protect everything under TAdminController
AuthenticationRequired :=
ControllerQualifiedClassName.Contains('TAdminController');
end;
OnAuthentication โ validate credentials
procedure TAuthentication.OnAuthentication(
const AContext: TWebContext;
const UserName, Password: string;
UserRoles: TList<string>;
var IsValid: Boolean;
const SessionData: TSessionData);
begin
// validate the credentials against your user store
IsValid := ValidateFromDB(UserName, Password);
if IsValid then
begin
UserRoles.Add('user');
if IsAdmin(UserName) then
UserRoles.Add('admin');
SessionData.AddOrSetValue('username', UserName);
end;
end;
OnAuthorization โ check roles per action
procedure TAuthentication.OnAuthorization(
const AContext: TWebContext;
UserRoles: TList<string>;
const ControllerQualifiedClassName, ActionName: string;
var IsAuthorized: Boolean);
begin
if ActionName.StartsWith('Admin') then
IsAuthorized := UserRoles.Contains('admin')
else
IsAuthorized := True;
end;
Login form pattern (browser-facing)
<form method="POST" action="/login">
<input name="username" type="text" required>
<input name="password" type="password" required>
<button type="submit">Login</button>
</form>
The /login route is handled automatically by UseJWTCookieAuthentication.
On success, it sets an HTTP-only cookie and redirects to the referrer or /web.
Reading the current user in a controller
function TWebController.Profile: String;
begin
ViewData['username'] := Context.LoggedUser.UserName;
ViewData['is_admin'] := Context.LoggedUser.Roles.Contains('admin');
Result := RenderView('profile/index');
end;
11. Custom TemplatePro Filters (TemplateProHelpersU.pas)
Registering filters
procedure TemplateProContextConfigure;
begin
TTProConfiguration.OnContextConfiguration :=
procedure(const CompiledTemplate: ITProCompiledTemplate)
begin
CompiledTemplate.AddFilter('currency', CurrencyFilter);
CompiledTemplate.AddFilter('timeago', TimeAgoFilter);
end;
end;
Call TemplateProContextConfigure once in Boot (inside BootConfigU.pas).
Writing a filter
uses System.Rtti, TemplatePro, TemplatePro.Types;
function CurrencyFilter(const Value: TValue;
const Parameters: TArray<TFilterParameter>): TValue;
begin
Result := FormatCurr('โฌ#,##0.00', Value.AsExtended);
end;
function TimeAgoFilter(const Value: TValue;
const Parameters: TArray<TFilterParameter>): TValue;
var
lDelta: TTimeSpan;
begin
lDelta := TTimeSpan.Subtract(Now, Value.AsExtended);
if lDelta.TotalMinutes < 1 then
Result := 'just now'
else if lDelta.TotalHours < 1 then
Result := Format('%d minutes ago', [Round(lDelta.TotalMinutes)])
else
Result := Format('%d hours ago', [Round(lDelta.TotalHours)]);
end;
Template usage:
{{:price|currency}}
{{:created_at|timeago}}
Dynamic external data source (OnGetValue)
CompiledTemplate.OnGetValue :=
procedure(const DataSource, Members: string;
var Value: TValue; var Handled: Boolean)
begin
if SameText(DataSource, 'config') then
begin
Value := GetConfigValue(Members); // e.g. 'config.siteName'
Handled := True;
end;
end;
Template: {{:config.siteName}}
12. DI / Services Pattern (ServicesU.pas)
Registering services
procedure RegisterServices(Container: IMVCServiceContainer);
begin
Container.RegisterType(
TPeopleService, IPeopleService, TRegistrationType.SingletonPerRequest);
Container.RegisterType(
TProductService, IProductService, TRegistrationType.Singleton);
end;
Registration types:
SingletonPerRequest โ one instance per HTTP request (most common for web)
Singleton โ one instance for the lifetime of the process
Transient โ new instance on every Resolve call
Resolving in a controller
function TWebController.Products: String;
var
lService: IProductService;
begin
lService := Context.ServiceContainerResolver.Resolve(TypeInfo(IProductService)) as IProductService;
ViewData['products'] := lService.GetAll;
Result := RenderView('products/index');
end;
Registering โ once, in the .dpr
TWebContext has no ServiceContainer property (only the read-only ServiceContainerResolver).
Registration happens once, before the server starts, and Build is mandatory:
RegisterServices(DefaultMVCServiceContainer);
DefaultMVCServiceContainer.Build;
Prefer injecting the service rather than resolving it by hand โ [MVCInject] on the controller's
constructor or on an action parameter. See the dmvcframework skill, reference/di-and-repository.md.
13. API JSON Sidecar (Controllers.APIU.pas)
The web application includes a JSON API controller alongside the web controller.
[MVCPath('/api')]
TAPIController = class(TMVCController)
public
[MVCPath('/server/info')]
[MVCHTTPMethod([httpGET])]
function GetServerInfo: IMVCResponse;
end;
function TAPIController.GetServerInfo: IMVCResponse;
begin
Result := OkResponse(
StrDict(
['application', 'serverTime', 'dmvcVersion'],
['MyApp', FormatDateTime('yyyy-mm-dd"T"hh:nn:ss', Now), DMVCFRAMEWORK_VERSION]
)
);
end;
StrDict(keys, values) returns a TMVCStringDictionary (stringโstring), serialized as a flat JSON
object. For a dictionary of objects, use ObjectDict() โ that is the one returning IMVCObjectDictionary.
14. Static Files
Static files are served from bin/www/ at the /static URL prefix.
bin/www/
css/style.css โ /static/css/style.css
favicon.ico โ /static/favicon.ico
favicon-32x32.png โ /static/favicon-32x32.png
site.webmanifest โ /static/site.webmanifest
Middleware declaration:
AEngine.AddMiddleware(
TMVCStaticFilesMiddleware.Create('/static', TPath.Combine(AppPath, 'www')));
In templates: <link rel="stylesheet" href="/static/css/style.css">
15. Environment Configuration (bin/.env)
Full reference โ profiles, precedence, ${}/$[] syntax, RequireKeys, the Boot order:
dmvcframework skill, reference/dotenv.md.
Key settings for the webapp template:
dmvc.server.port=8080
dmvc.view_path=templates
dmvc.default.view_file_extension=html
dmvc.view_cache=false
JWT_SECRET=
JWT_ISSUER=MyApp
JWT_COOKIE_SECURE=false
logger.config.file=loggerpro.json
Profile-specific overrides: .env.test, .env.prod (layered by UseProfile).
16. Scaffolding Workflow โ Adding a New Page
Step 1 โ Add actions to the web controller
In Controllers.HomeU.pas (or a new controller unit):
[MVCPath('/products')]
[MVCHTTPMethod([httpGET])]
[MVCProduces(TMVCMediaType.TEXT_HTML)]
function Products: String;
[MVCPath('/products/fragment/list')]
[MVCHTTPMethod([httpGET])]
[MVCProduces(TMVCMediaType.TEXT_HTML)]
function GetProductListFragment: String;
function TWebController.Products: String;
begin
ViewData['page_id'] := 'products';
Result := RenderView('products/index');
end;
function TWebController.GetProductListFragment: String;
var
lProducts: TObjectList<TProduct>;
begin
lProducts := fProductService.GetAll;
try
ViewData['products'] := lProducts; // ViewData owns nothing โ free it below
Result := RenderView('products/_list'); // partial template, no {{extends}}
finally
lProducts.Free;
end;
end;
Step 2 โ Create the template
Create bin/templates/products/index.html:
{{extends "../baselayout.html"}}
{{block "title"}}Products{{endblock}}
{{block "body"}}
<div class="hero">
<h1>Products</h1>
</div>
<div hx-get="/web/products/fragment/list"
hx-trigger="load"
hx-swap="innerHTML">
<p aria-busy="true">Loading products...</p>
</div>
{{endblock}}
Step 3 โ Add nav link in baselayout.html
<li>
<a href="/web/products"
{{if page_id|eq,"products"}}aria-current="page"{{endif}}>
Products
</a>
</li>
Step 4 โ Register new controller (if separate unit)
In EngineConfigU.pas:
AEngine.AddController(TProductsController);
17. Common Pitfalls
| Pitfall | Fix |
|---|
Action is procedure instead of function | All web actions must be function: return String for HTML, IMVCResponse for JSON. |
Freeing nothing after ViewData['x'] := lObj | ViewData owns nothing. Free the object yourself in a finally after RenderView. |
| Hand-building HTML for HTMX fragments | Use the same RenderView + ViewData['ispage'] := not Request.IsHTMX and let the layout drop its chrome. |
Writing HX-* headers by hand | uses MVCFramework.HTMX โ Response.HXSetRedirect/HXSetPushUrl/HXSetReswap/HXSetRetarget/HXTriggerClientEvent, Request.IsHTMX/HXGetTarget. |
ViewData not set before RenderView | OnBeforeAction sets shared data; page-specific data must be set in the action function body before Result := RenderView(...). |
| Template extends wrong relative path | home/index.html โ {{extends "../baselayout.html"}}. error.html (same folder as baselayout) โ {{extends "baselayout.html"}}. |
JWT_COOKIE_SECURE=true with HTTP | Cookie won't be sent by the browser over plain HTTP. Use false during local dev, true in production with HTTPS. |
| View cache enabled during development | dmvc.view_cache=true caches compiled templates โ template changes won't appear until restart. Use false in dev. |
SetExceptionHandler handles all requests | Check WebContext.Request.ClientPreferHTML first and Exit if false โ otherwise API clients get an HTML error page. |
| Static file not found | File must be under bin/www/. The URL prefix is /static, not /www. Check middleware order โ static files should be declared after redirect and auth. |
| Template variable silently empty | TemplatePro doesn't error on missing keys by default. Add a breakpoint after ViewData[...] assignments to verify they are set. |
{{for item in list}} doesn't iterate | The list must be a TObjectList<T> or array registered in ViewData. Ensure the service returns a non-nil, non-empty list. |
| HTMX response headers ignored | HTMX checks for HX-* headers only on responses to HTMX requests (requests with HX-Request: true header). Verify the button/div has a hx-* trigger attribute. |
hx-swap-oob elements not swapped | Each oob element must have an id that matches an existing DOM element. HTMX swaps by ID. |
18. Key Units Reference
| Unit | Purpose |
|---|
MVCFramework.View.Renderers.TemplatePro | TMVCTemplateProViewEngine |
TemplatePro | TTProCompiler.CompileAndRender, TTProConfiguration, ITProCompiledTemplate |
TemplatePro.Types | TFilterParameter |
MVCFramework.Middleware.JWT | UseJWTCookieAuthentication, UseJWTMiddleware |
MVCFramework.Middleware.Session | UseFileSessionMiddleware |
MVCFramework.Middleware.Redirect | TMVCRedirectMiddleware |
MVCFramework.Middleware.StaticFiles | TMVCStaticFilesMiddleware |
MVCFramework.Middleware.Compression | TMVCCompressionMiddleware |
MVCFramework.JWT | TJWT, TJWTClaimsSetup, TJWTCheckableClaim |
MVCFramework.Commons | TMVCMediaType, AppPath, DMVCFRAMEWORK_VERSION |
MVCFramework.Container | IMVCServiceContainer, TRegistrationType |
MVCFramework.DotEnv | dotEnv, dotEnvConfigure, NewDotEnv |
LoggerPro.Config | TLoggerProConfig.BuilderFromJSONFile |
19. Sample Reference Project
In the DelphiMVCFramework repository (https://github.com/danieleteti/delphimvcframework/tree/master/samples):
| Sample | What it shows |
|---|
samples/htmx_website_with_templatepro/ | TemplatePro + HTMX website: one action serves page and fragment via ispage, baselayout with blocks, partials, custom filters |
samples/serversideviews_templatepro/ | The view engine in depth: inheritance, filters, RenderViews, CSV/non-HTML views, dataset in ViewData |
samples/wizard_showcase/web/ | Minimal-API flavour of a web app: .AsWeb, sessions, RenderView from lambda handlers |
Or generate one with the IDE wizard (preset Web Application) โ that is the layout this skill assumes.