All Articles

So I wrote a forum application in BoxLang

Robert shares his experience building DismalThreads, a modern forum application that evolved from his 2022 project renegade-forums. He rewrote it using BoxLang 1.0 and CBWire, a ColdBox module that enables reactive interfaces without JavaScript. He prefers CBWire over frameworks like Vue or React since it uses plain CFML. The site features nested comments, infinite scrolling feeds, notifications, and supports both text and link posts with automatic metadata retrieval.

I do not always have a lot of free time, but when I do, I usually try to work on projects that push me out of my comfort zone. You can only write so many to-do list editors before it just becomes... meh.

Sometime in 2022, I started renegade-forums. The name was a throwback to my BBS roots, when I ran a Renegade BBS node as a sysop. The last really notable CFML-based forum software was Galleon Forums, but that has been deprecated for over a decade at this point. It seemed like a fun side project so off I went.

I made good progress on the application and laid the groundwork: a thousand or so lines of JavaScript to handle UI interactions and a lot of API endpoints to communicate with the back-end services. The basic functionality of adding and editing posts and comments was there, but it was a fragile system and prone to failure.

Then in May of 2025, BoxLang 1.0 was released, and I thought this would be a good project to use to learn the application server. It was also around this time that I started doing a deep dive into CBWire, which was first introduced at the Into the Box 2021 conference by Grant Copley. If you are not familiar with CBWire, it is a ColdBox module for building reactive, dynamic, and modern interfaces without leaving CFML and with little-to-no JavaScript. With these changes in mind, I started rewriting renegade-forums, and the end result is now DismalThreads.

A CTO friend of mine asked if I preferred using CBWire over a UI framework like Vue or React. My response was: absolutely. It allows you to generate responsive pages without having to handle all of the UI interactions in JavaScript — you just write plain CFML. As an example, here is the template file for the "Create Account" screen.

Account creation screen
Account creation screen

<bx:output>
<div wire:key="login-modal" class="text-white">
	<bx:if !login_mode>

		<h5 id="login-modal-title" class="login-title mb-4">Create Account</h5>

		<form wire:submit.prevent="createAccount" wire:loading.class="opacity-50">

			<div class="login-field mb-3">
				<label class="login-label" for="signup_user">User</label>
				<div class="login-input-group">
					<input
						wire:model.lazy="signupUser"
						id="signup_user"
						type="text"
						class="login-input login-input--grouped #getSignupUserClass()#"
						placeholder="User name"
						autocomplete="username"
					/>
					<button class="login-btn login-btn--icon" type="button" wire:click="generateUsername" aria-label="Generate a username">
						<i class="bi bi-arrow-clockwise" aria-hidden="true"></i>
					</button>
				</div>
				<div class="invalid-feedback">#signupUserError#</div>
			</div>

			<div class="login-field mb-3">
				<label class="login-label" for="signup_email">Email</label>
				<input
					wire:model.lazy="signupEmail"
					id="signup_email"
					type="email"
					class="login-input #getSignupEmailClass()#"
					placeholder="Email"
					autocomplete="email"
				/>
				<div class="invalid-feedback">#signupEmailError#</div>
			</div>

			<div class="login-field mb-3">
				<label class="login-label" for="signup_pass">Password</label>
				<input
					wire:model.lazy="signupPass"
					id="signup_pass"
					type="password"
					class="login-input #getSignupPassClass()#"
					placeholder="Password"
					autocomplete="new-password"
				/>
				<div class="invalid-feedback">#signupPassError#</div>
			</div>

			<div class="login-field mb-3">
				<label class="login-label" for="signup_confirm">Confirm Password</label>
				<input
					wire:model.lazy="signupConfirmPass"
					id="signup_confirm"
					type="password"
					class="login-input #getSignupConfirmPassClass()#"
					placeholder="Confirm Password"
					autocomplete="new-password"
				/>
				<div class="invalid-feedback">#signupConfirmPassError#</div>
			</div>

			<div class="login-switch mt-3">
				Already have an account?
				<button type="button" wire:click="switchMode" class="login-link">Log In</button>
			</div>

			<div class="login-actions mt-4">
				<button type="button" class="login-btn login-btn--secondary" data-bs-dismiss="modal">Close</button>
				<button type="submit" class="login-btn login-btn--primary" wire:loading.attr="disabled">Create</button>
			</div>

		</form>

	</bx:if>

</div>
</bx:output>

The view is straightforward — all the magic is actually happening in the component file of the wire. Below is the code that runs when a new account is created. As you can see, it is easy to integrate other modules such as cbvalidation when needed.

	function createAccount(){
		var createConstraints = {
			signupUser : {
				required        : true,
				requiredMessage : "User name is required",
				unique          : { table : "Users", column : "user_name" },
				uniqueMessage   : "User already exists"
			},
			signupEmail : {
				required        : true,
				requiredMessage : "Email is required",
				unique          : { table : "Users", column : "email" },
				uniqueMessage   : "Email already exists"
			},
			signupPass : {
				required        : true,
				requiredMessage : "Password is required"
			},
			signupConfirmPass : {
				required        : true,
				requiredMessage : "Confirm password is required",
				sameAs          : "signupPass",
				sameAsMessage   : "Confirm password does not match password"
			}
		};

		var results = validate( target = data, constraints = createConstraints );

		data.signupValidated      = true;
		data.signupUserError      = "";
		data.signupEmailError     = "";
		data.signupPassError      = "";
		data.signupConfirmPassError = "";

		if ( results.hasErrors() ) {
			results.getErrors().each( ( err ) => {
				data[ err.getField() & "Error" ] = err.getMessage();
			} );
			return;
		}

		getInstance( "User" )
			.setUser_name( data.signupUser )
			.setEmail( data.signupEmail )
			.hashPassword( data.signupPass )
			.save();

		var newUser = getInstance( "User" ).getUserByName( data.signupUser );
		if ( newUser.hasLoaded() ) {
			// handle setting the user session and redirect
		}
	}

The login modal was the first item switched over to a CBWire component, and after that I was hooked. It was very easy to wire (pun intended) a reactive account screen, and I did not write a single line of JavaScript or API code.

From there, I gradually reworked everything as CBWire components.

All feeds have been reworked as wires with sub-wires for feed elements. All feeds also support infinite scrolling until you reach the end, providing a Reddit-like experience.

The site supports alerts and notifications — you will receive a notification when someone comments on one of your posts or comments, as well as for every five upvotes your post receives.

You can submit either a text-based post or a link post. If the post type is a link, the application will attempt to retrieve the image and description for that URL and display them in the feed. You can also add your own summary if you like. Text-based posts may be edited at any time; however, link posts may only be deleted if a change is needed.

A post with comments in a private forum
A post with comments in a private forum

I spent quite a bit of time getting the comment system working to handle nested comment trees, and overall I am happy with how things turned out. You can edit or delete your own comments. If a comment has replies, it will be marked as deleted while the child comments remain visible so they are not orphaned.

It is not currently possible for users to create their own forums, but that functionality would not be difficult to add if there is enough interest.

I also learned a lot about CommandBox and BoxLang server management — some of it the hard way. If you do not have a specific BoxLang version pinned for cfengine, you need to ensure that all required modules are listed in the onServerInitialInstall block of the server.json scripts section. Otherwise, when your server restarts and downloads a newer BoxLang version, you will find yourself wondering why your site is suddenly throwing errors because modules are missing.

To cut down on database reads, I use caching aggressively. Originally I was using the BoxLang built-in cache methods writing to a ConcurrentSoftReferenceStore, as I was running into issues with the JDBCStore. I was able to work with the BoxLang team to resolve this, and as of BoxLang 1.12.0-snapshot, the JDBCStore is working as expected, enabling caching at the database level rather than in memory.

Overall, it was a great learning experience and considerably more challenging than another to-do list. If you would like to check out the site, you can find it here: https://dismalthreads.com.