Tutorial: Building a Web App

HarbourBuilder includes a built-in TWebServer component that lets you serve web pages and JSON APIs directly from your application. In this tutorial you will build a simple TODO web app with both a desktop GUI and a browser-based interface running side by side.

Step 1: Create the Project

  1. Create a new project called TodoWebApp via File → New Project.
  2. Open main.prg in the Code Editor.
  3. We will build a desktop form for managing todos and a web server that exposes the same data.

Step 2: Set Up the Web Server

Drop a TWebServer from the Internet palette tab onto the form, or create it in code. Set the port and define your routes.

#include "hbbuilder.ch"

static aTodos := {}  // Shared todo list

function Main()

   local oForm, oServer, oBrw, oGet, oBtnAdd
   local cTask := ""

   // --- Web server setup ---
   DEFINE WEBSERVER oServer PORT 8080

   oServer:Route( "GET",  "/",          { |oReq, oRes| ServeHomePage( oReq, oRes ) } )
   oServer:Route( "GET",  "/api/todos",  { |oReq, oRes| GetTodosJSON( oReq, oRes ) } )
   oServer:Route( "POST", "/api/todos",  { |oReq, oRes| AddTodoJSON( oReq, oRes ) } )
   oServer:Route( "DELETE", "/api/todos/:id", { |oReq, oRes| DeleteTodoJSON( oReq, oRes ) } )

   oServer:Start()

   // --- Desktop UI ---
   DEFINE FORM oForm TITLE "TODO App (Desktop + Web on port 8080)" ;
      SIZE 600, 500 FONT "Segoe UI", 10

   @ 10, 10 BROWSE oBrw ;
      OF oForm SIZE 560, 350 ;
      HEADERS { "#", "Task", "Status" } ;
      WIDTHS  { 40, 350, 100 }

   @ 380, 10 GET oGet VAR cTask OF oForm SIZE 400, 24

   @ 380, 420 BUTTON oBtnAdd PROMPT "Add Task" ;
      OF oForm SIZE 100, 32 ;
      ACTION ( AAdd( aTodos, { oGet:GetValue(), "pending" } ), ;
              RefreshGrid( oBrw ), oGet:SetValue( "" ) )

   RefreshGrid( oBrw )

   oForm:OnClose := { || oServer:Stop(), .T. }

   ACTIVATE FORM oForm CENTERED

return nil
Desktop + Web in one executable

The TWebServer runs on a background thread, so your desktop form remains fully interactive. Both the GUI and the browser see the same aTodos array. Changes from either side are immediately visible to the other.

Step 3: Serve an HTML Page

The root route / serves a complete HTML page. Use oRes:SendHTML() to return HTML content.

static function ServeHomePage( oReq, oRes )

   local cHTML := '<!DOCTYPE html>' + ;
      '<html><head><title>TODO App</title>' + ;
      '<style>body{font-family:sans-serif;max-width:600px;margin:2em auto}' + ;
      'input{padding:8px;width:70%} button{padding:8px 16px}' + ;
      'li{padding:4px 0}</style></head><body>' + ;
      '<h1>TODO List</h1>' + ;
      '<input id="task" placeholder="New task...">' + ;
      '<button onclick="addTask()">Add</button>' + ;
      '<ul id="list"></ul>' + ;
      '<script>' + ;
      'async function load(){let r=await fetch("/api/todos");' + ;
      'let d=await r.json();let h="";' + ;
      'd.forEach((t,i)=>h+="<li>"+t[0]+" ["+t[1]+"] " + ;
      '+"<a href=# onclick=del("+i+")>x</a></li>");' + ;
      'document.getElementById("list").innerHTML=h}' + ;
      'async function addTask(){let v=document.getElementById("task").value;' + ;
      'await fetch("/api/todos",{method:"POST",' + ;
      'headers:{"Content-Type":"application/json"},' + ;
      'body:JSON.stringify({task:v})});load()}' + ;
      'async function del(i){await fetch("/api/todos/"+i,' + ;
      '{method:"DELETE"});load()}' + ;
      'load();</script></body></html>'

   oRes:SendHTML( cHTML )

return nil

Step 4: Return JSON for the API

The API routes use oRes:SendJSON() to return structured data that the browser JavaScript (or any HTTP client) can consume.

static function GetTodosJSON( oReq, oRes )

   oRes:SendJSON( hb_jsonEncode( aTodos ) )

return nil

static function AddTodoJSON( oReq, oRes )

   local hBody := hb_jsonDecode( oReq:cBody )

   AAdd( aTodos, { hBody[ "task" ], "pending" } )
   oRes:SendJSON( '{"status":"ok"}' )

return nil

static function DeleteTodoJSON( oReq, oRes )

   local nId := Val( oReq:Param( "id" ) ) + 1

   if nId >= 1 .and. nId <= Len( aTodos )
      ADel( aTodos, nId )
      ASize( aTodos, Len( aTodos ) - 1 )
   endif

   oRes:SendJSON( '{"status":"ok"}' )

return nil

Step 5: Refresh the Desktop Grid

static function RefreshGrid( oBrw )

   local aData := {}, n

   for n := 1 to Len( aTodos )
      AAdd( aData, { n, aTodos[ n ][ 1 ], aTodos[ n ][ 2 ] } )
   next

   oBrw:SetArray( aData )
   oBrw:Refresh()

return nil

Step 6: Build and Test

  1. Press F9 to build and run.
  2. The desktop form appears with an empty task list. Add a few tasks using the GUI.
  3. Open a browser and navigate to http://localhost:8080.
  4. You see the same tasks rendered as HTML. Add or delete tasks from the browser.
  5. Switch back to the desktop — the data is shared in real time.

Architecture Overview

graph TB subgraph "Single HarbourBuilder Executable" A["Desktop UI
TForm + TBrowse"] --- C["Shared Data
aTodos Array"] B["TWebServer
Port 8080"] --- C end D["Browser
http://localhost:8080"] -->|"HTTP GET/POST/DELETE"| B A -->|"Direct array access"| C style A fill:#58a6ff,stroke:#388bfd,color:#0d1117 style B fill:#3fb950,stroke:#2ea043,color:#0d1117 style C fill:#d2a8ff,stroke:#bc8cff,color:#0d1117 style D fill:#f0883e,stroke:#d18616,color:#0d1117
Serving static files

For larger web apps, use oServer:Static( "/assets", "./www" ) to serve CSS, JavaScript, and image files from a local folder instead of embedding HTML in strings.

Next step

Ready to add intelligence to your app? Continue to the AI Integration tutorial to connect to local LLMs and run inference directly from your application.

On This Page

Getting Started Component Palette IDE Features Tutorials Reference Platforms Step 1: Create the Project Step 2: Set Up the Web Server Step 3: Serve an HTML Page Step 4: Return JSON for the API Step 5: Refresh the Desktop Grid Step 6: Build and Test Architecture Overview