Learn the fundamentals of nojs by building a simple, reactive counter component. In just 10 minutes, you'll understand how components, state, data binding, and event handling work.
What you'll build: A button-based counter that increments and displays a live count. What you'll learn: Components, reactive state, data binding, event binding, AOT compilation. No routing, no complexity — just the essentials.
- Go 1.25+ — https://go.dev/dl/
- Make — pre-installed on Linux/macOS; use WSL on Windows
- Python 3 — Required to serve the app locally. This tutorial uses
python3 -m http.serverfor simplicity; any static file server works, but we assume Python 3 is available so you can focus on learning nojs, not configuring servers.
Estimated time: 10 minutes
We've provided a starter project with all the boilerplate already in place. Clone it and you're ready to start:
git clone https://github.com/ForgeLogic/nojs-tutorials.git
cd nojs-tutorials
git checkout counter-tutorialHere's what's already in the project:
counter-tutorial/
├── internal/app/components/counter/
│ ├── counter.go ← You'll fill this in (Step 1-2)
│ └── Counter.gt.html ← You'll fill this in (Step 3)
├── wwwroot/
│ ├── index.html ← Ready to go
│ ├── core.js ← Ready to go
│ └── (wasm_exec.js — copy it in Step 0)
├── main.go ← You'll fill this in (Step 5)
├── go.mod ← Already configured
├── Makefile ← Ready to use
└── README.md ← Quick reference
The wasm_exec.js file is the Go runtime bridge for WebAssembly. It must match your installed Go version.
cp "$(go env GOROOT)/lib/wasm/wasm_exec.js" wwwroot/This copies it from your Go installation to the project.
Open internal/app/components/counter/counter.go and fill in the struct.
Currently, it looks like this:
//go:build js || wasm
package counter
import "github.com/ForgeLogic/nojs/runtime"
type Counter struct {
runtime.ComponentBase
// TODO: Add Count field here
}Replace the TODO with:
//go:build js || wasm
package counter
import "github.com/ForgeLogic/nojs/runtime"
type Counter struct {
runtime.ComponentBase
Count int
}//go:build js || wasm— Build tag that tells Go to only compile this file for WebAssembly targets, not during test builds.runtime.ComponentBase— Embedded struct that gives your component lifecycle methods and theStateHasChanged()function.Count int— Your component's state. Must be exported (capitalized) — templates can only access public fields.
Still in counter.go, add a method that mutates the state:
func (c *Counter) Increment() {
c.Count++
c.StateHasChanged()
}c.Count++— Increment the counter value.c.StateHasChanged()— Tell the framework "My state changed, please re-render me." This triggers the virtual DOM diffing and patching process.
Open internal/app/components/counter/Counter.gt.html and replace the TODO with:
<div>
<p>Count: {Count}</p>
<button @onclick="Increment">Click me</button>
</div>{Count}— Data binding. Inserts theCountfield value into the HTML. The receiver is inferred by the compiler — you never write{c.Count}, just{Count}. The compiler knows the receiver is implicit.@onclick="Increment"— Event binding. When the button is clicked, calls theIncrement()method on your component.
Run the compiler to generate the Render() method from your template:
make installThen:
make fullmake installdownloads framework dependencies and installs thenojsccompiler.make fullruns the compiler:nojscparsesCounter.gt.html- Generates
Counter.generated.gowith the auto-generatedRender()method - Compiles Go to WASM (
main.wasm)
- You'll see the new
Counter.generated.gofile in your project directory. - All template validation happens at build time, not runtime. If there are errors, the compiler will report them.
Open main.go and replace the TODO with:
//go:build js || wasm
package main
import (
"github.com/ForgeLogic/nojs/runtime"
"nojs-counter/internal/app/components/counter"
)
func init() {
runtime.Mount("#app", &counter.Counter{})
}
func main() {
select {}
}Note: Update the import path to match your module name in
go.mod. If your module isgithub.com/myuser/my-counter-app, change the import to"github.com/myuser/my-counter-app/internal/app/components/counter".
runtime.Mount("#app", &counter.Counter{})— Finds the DOM element with idappand renders your component there.select {}— Keeps the Go runtime running and listening for events. Don't remove this.
Recompile with your updated main.go:
make fullThen serve the app:
make serveYou should see:
Serving HTTP on 0.0.0.0 port 9090 (http://0.0.0.0:9090/) ...
Open your browser to http://localhost:9090
You should see:
- A paragraph:
Count: 0 - A button:
Click me
Click the button. The count increments. Click again. It goes up. No page refresh, no manual DOM queries — just pure reactive updates.
Open the browser DevTools (F12 → Console). You should see:
WebAssembly module loaded.
Congratulations! You've built your first nojs component. 🎉
Here's the data flow:
- User clicks button → Browser's event listener triggers
Increment()method called → You wrote this in Step 2c.Count++→ State is mutatedStateHasChanged()→ Framework notifiedRender()called → Compiler-generated method creates new virtual DOM- Virtual DOM diffed → New tree compared against old tree
- Minimal changes calculated → Only
{Count}changed - Real DOM patched → Browser updates only the
<p>text - Result: Count reflects the new value, instantly
This is the virtual DOM cycle — the core of nojs's performance. No full page reload, no querySelector, no manual textContent assignments. Just: mutate state → call StateHasChanged() → framework handles the rest.
Try this on your own:
- Add a
Decrement()method to your component (copyIncrement, change++to--) - Update the template to add a decrement button
- Rebuild (
make full) and test
- Quick Guide — Reference for all nojs features
- List Rendering — Render dynamic lists efficiently
- Inline Conditionals — Show/hide elements based on state
- Signals — Reactive updates across components
- Full Demo App — Routing, forms, layouts
Make sure you ran make install first. This installs the compiler.
Make sure you called StateHasChanged() in your event handler. Without it, the framework doesn't know to re-render.
You need to copy it (see Step 0). It's version-specific to your Go installation.
Write the full error message from the terminal. Common issues:
- Typo in field name (remember: must be capitalized like
Count, notcount) - Typo in method name in
@onclick="..." - Invalid HTML syntax in the template