JavaScript & Alpine.js Guide
This guide covers client-side interactivity in Varel applications using Alpine.js.
Table of Contents
- Introduction
- Alpine.js Basics
- Reusable Components
- Integration with VeeMarker
- Best Practices
- Common Patterns
Introduction
Varel includes Alpine.js 3.14 as its JavaScript framework for adding client-side interactivity. Alpine.js is:
- ✅ Lightweight - Only 15KB minified/gzipped
- ✅ No build step - Works directly in templates
- ✅ Reactive - Automatic UI updates when data changes
- ✅ Server-friendly - Perfect for server-rendered apps like Varel
- ✅ Included locally - No CDN dependency, works offline
Why Alpine.js?
Alpine.js provides the reactivity of Vue/React with the simplicity of jQuery. It's perfect for adding interactive features to server-rendered Varel applications without the complexity of a full SPA framework.
Traditional approach (jQuery-style):
// Lots of manual DOM manipulation
$('#copy-btn').click(function() {
navigator.clipboard.writeText($('#url').val());
$(this).text('✓ Copied!');
setTimeout(() => $(this).text('Copy'), 2000);
});
Alpine.js approach:
<!-- Declarative, reactive -->
<div x-data="{ copied: false }">
<button @click="
navigator.clipboard.writeText('${url}');
copied = true;
setTimeout(() => copied = false, 2000)
">
<span x-show="!copied">Copy</span>
<span x-show="copied">✓ Copied!</span>
</button>
</div>
Alpine.js Basics
Installation
Alpine.js is automatically included in all new Varel projects. It's loaded in views/layouts/base.html.vtpl:
<!-- Alpine.js - Local copy for offline support -->
<script defer src="/static/js/alpine.min.js"></script>
The defer attribute ensures Alpine loads after the HTML is parsed.
Core Directives
Alpine uses HTML attributes (directives) to add interactivity:
x-data - Component State
Defines reactive data for a component:
<div x-data="{ count: 0, name: 'Alice' }">
<p>Count: <span x-text="count"></span></p>
<p>Name: <span x-text="name"></span></p>
</div>
x-show - Conditional Visibility
Toggles CSS display property:
<div x-data="{ open: false }">
<button @click="open = !open">Toggle</button>
<div x-show="open">This content toggles</div>
</div>
Tip: Use x-cloak to hide content until Alpine loads:
<div x-show="open" x-cloak>Content</div>
x-if - Conditional Rendering
Adds/removes elements from DOM (more performant for heavy content):
<template x-if="loggedIn">
<div>Welcome back!</div>
</template>
@click - Event Handlers
Shorthand for x-on:click:
<button @click="count++">Increment</button>
<button @click="handleSubmit()">Submit</button>
Other events: @submit, @input, @change, @keydown, @mouseenter, etc.
x-model - Two-Way Binding
Syncs input values with data:
<div x-data="{ search: '' }">
<input x-model="search" placeholder="Search...">
<p>Searching for: <span x-text="search"></span></p>
</div>
x-text - Text Content
Sets element's text content:
<span x-text="username"></span>
<!-- vs -->
<span>${username}</span> <!-- VeeMarker, server-side -->
x-html - HTML Content
Sets element's HTML content (be careful with XSS):
<div x-html="descriptionHtml"></div>
x-bind - Attribute Binding
Bind attributes reactively (shorthand is :):
<img :src="imageUrl" :alt="imageAlt">
<button :disabled="isProcessing">Submit</button>
<div :class="{ 'active': isActive, 'error': hasError }">...</div>
Reusable Components
Varel includes pre-built Alpine.js components as VeeMarker macros in views/shared/alpine_components.vtpl.
Using Components
Include the components file in your views:
<#include "shared/alpine_components.vtpl">
<!-- Now use any component -->
<#assign shortUrl = "http://localhost:8080/" + url.short_code>
<@copyButton text=shortUrl label="Copy URL" />
Passing Variables to Macros
Important: VeeMarker does NOT interpolate ${} expressions inside quoted macro attribute strings.
❌ Wrong - This will NOT work:
<@copyButton text="http://localhost:8080/${url.short_code}" />
<!-- Result: Copies literal "${url.short_code}" -->
<@copyButton text="${shortUrl}" />
<!-- Result: Copies literal "${shortUrl}" -->
✅ Correct - Two ways to pass variables:
Option 1: Pass variable by reference (no quotes)
<#assign shortUrl = "http://localhost:8080/" + url.short_code>
<@copyButton text=shortUrl label="Copy URL" />
Option 2: Use literal strings (for static values)
<@copyButton text="https://example.com/static-url" label="Copy" />
Rule: If you need dynamic values, use <#assign> to build the value first, then pass the variable name without quotes.
Available Components
1. Copy Button
Copy text to clipboard with visual feedback:
<@copyButton
text="https://example.com/abc123"
label="Copy"
successLabel="✓ Copied!"
class="my-custom-class"
/>
Parameters:
text- Text to copy (required)label- Button label (default: "Copy")successLabel- Success message (default: "✓ Copied!")class- Additional CSS classes (optional)
Example in URL shortener:
<#list urls as url>
<#assign fullUrl = request.base_url + "/" + url.short_code>
<tr>
<td>${url.short_code}</td>
<td>${url.original_url}</td>
<td>
<@copyButton text=fullUrl label="📋 Copy" />
</td>
</tr>
</#list>
2. Confirm Delete
Delete button with confirmation dialog:
<#assign deleteAction = "/products/" + product.id>
<@confirmDelete
action=deleteAction
itemName=product.name
buttonText="Delete"
confirmMessage="Are you sure?"
buttonClass="btn-danger"
/>
Parameters:
action- Form action URL (required)itemName- Item name for confirmation (optional)buttonText- Button label (default: "Delete")confirmMessage- Confirmation text (default: "Are you sure?")buttonClass- Button CSS class (default: "btn-danger")
Features:
- Modal overlay with backdrop
- Click outside or ESC key to cancel
- Form uses method override for DELETE
3. Toast Notifications
Global toast notification system:
Step 1: Add to layout (once):
<!-- In base.html.vtpl, before </body> -->
<#include "shared/alpine_components.vtpl">
<@toastContainer />
Step 2: Trigger from templates:
<#if success_message??>
<script>
window.dispatchEvent(new CustomEvent('toast', {
detail: { message: '${success_message?js_string}', type: 'success' }
}));
</script>
</#if>
Step 3: Or trigger from JavaScript:
window.dispatchEvent(new CustomEvent('toast', {
detail: { message: 'URL shortened!', type: 'success' }
}));
Toast types:
success- Green, for successful operationserror- Red, for errorswarning- Yellow, for warningsinfo- Blue, for information (default)
4. Dropdown Menu
Simple dropdown menu:
<@dropdown buttonText="Actions" buttonClass="btn">
<a href="/edit" class="dropdown-item">Edit</a>
<a href="/delete" class="dropdown-item">Delete</a>
<a href="/archive" class="dropdown-item">Archive</a>
</@dropdown>
Features:
- Click outside to close
- Smooth transitions
- Keyboard accessible
5. Toggle Switch
Checkbox styled as toggle switch:
<@toggleSwitch
name="published"
checked=product.published
label="Published"
onColor="#27ae60"
offColor="#95a5a6"
/>
Parameters:
name- Form field name (required)checked- Initial state (default: false)label- Label text (optional)onColor- Color when on (default: green)offColor- Color when off (default: gray)
6. Tabs
Tabbed interface:
<@tabs tabList=[
{'id': 'details', 'label': 'Details'},
{'id': 'reviews', 'label': 'Reviews'},
{'id': 'related', 'label': 'Related Products'}
] defaultTab="details">
<div data-tab="details">
<h3>Product Details</h3>
<p>${product.description}</p>
</div>
<div data-tab="reviews">
<h3>Customer Reviews</h3>
<!-- Reviews here -->
</div>
<div data-tab="related">
<h3>Related Products</h3>
<!-- Related products here -->
</div>
</@tabs>
Integration with VeeMarker
Alpine.js and VeeMarker work together seamlessly:
VeeMarker Built-in Functions for Alpine
VeeMarker provides three built-in functions specifically for Alpine.js integration:
?js_string - Escape JavaScript Strings
Escapes strings for safe embedding in JavaScript code:
<div x-data='{ name: "${product.name?js_string}" }'>
<p x-text="name"></p>
</div>
Escapes:
- Backslashes:
\→\\ - Single quotes:
'→\' - Double quotes:
"→\" - Newlines:
\n→\\n - Carriage returns:
\r→\\r - Tabs:
\t→\\t
?html - Escape HTML Characters
Escapes HTML special characters (useful for displaying user content safely):
<div x-data='{ content: "${user_input?html}" }'>
<p x-text="content"></p>
</div>
Escapes:
&→&<→<>→>"→"'→'
?alpine_json - Convert to JSON for x-data
Converts V data structures to JSON for Alpine's x-data attribute:
<!-- Simple usage with entire object -->
<div x-data='${product?alpine_json}'>
<h2 x-text="name"></h2>
<p>$<span x-text="price"></span></p>
</div>
<!-- With array of objects -->
<div x-data='{ products: ${products?alpine_json} }'>
<template x-for="product in products" :key="product.id">
<div x-text="product.name"></div>
</template>
</div>
Handles all V types:
- Strings: Escaped and quoted
- Numbers (int, f64): Output as-is
- Booleans:
true/false - Maps: Converted to JSON objects
- Arrays: Converted to JSON arrays
Example with controller:
// controllers/products.v
pub fn (c ProductsController) show(mut ctx varel.Context) varel.Response {
product := c.get_product(ctx.param('id')!)!
return ctx.render('products/show', {
'product': veemarker.to_map(product) // Convert struct to map
})
}
<!-- views/products/show.vtpl -->
<div x-data='${product?alpine_json}'>
<h1 x-text="name"></h1>
<p class="price">$<span x-text="price"></span></p>
<div x-show="in_stock">
<button @click="addToCart()">Add to Cart</button>
</div>
<div x-show="!in_stock">
<p class="out-of-stock">Out of Stock</p>
</div>
</div>
Varel Context Helper: render_alpine()
Varel provides a specialized rendering method for Alpine.js templates that simplifies data handling and injects Alpine.js utilities.
Method Signature
pub fn (mut ctx Context) render_alpine(template_path string, data map[string]vm.Any) Response
What It Does
- Accepts VeeMarker
Anydata - Works with complex data structures (maps, arrays, structs) - Injects Alpine.js version - Adds
_alpine_versionfor debugging/feature detection - Uses layout rendering - Automatically wraps template in layout (like
render()) - Optimized for Alpine.js - Designed for templates using Alpine.js components
Basic Usage
// controllers/products.v
import leafscale.veemarker as vm
pub fn (c ProductsController) show(mut ctx varel.Context) varel.Response {
product := c.get_product(ctx.param('id')!)!
// Use render_alpine for templates with Alpine.js
return ctx.render_alpine('products/show', {
'product': vm.to_map(product) // Convert struct to map
})
}
<!-- views/products/show.vtpl -->
<div x-data='${product?alpine_json}'>
<h1 x-text="name"></h1>
<p class="price">$<span x-text="price"></span></p>
<div x-show="in_stock">
<button @click="addToCart()">Add to Cart</button>
</div>
</div>
<!-- Alpine.js version available for debugging -->
<#if _alpine_version??>
<script>console.log('Alpine.js version: ${_alpine_version}');</script>
</#if>
With Multiple Data Items
// controllers/products.v
pub fn (c ProductsController) index(mut ctx varel.Context) varel.Response {
products := c.get_all_products()!
// Convert array of structs to array of maps
mut product_maps := []vm.Any{}
for product in products {
product_maps << vm.to_map(product)
}
return ctx.render_alpine('products/index', {
'products': product_maps
'page_title': 'All Products'
})
}
<!-- views/products/index.vtpl -->
<div x-data='{ items: ${products?alpine_json}, selected: [] }'>
<h1>${page_title}</h1>
<template x-for="product in items" :key="product.id">
<div class="product-card">
<h2 x-text="product.name"></h2>
<p>$<span x-text="product.price"></span></p>
<button @click="selected.push(product.id)">Add to Cart</button>
</div>
</template>
<p>Cart: <span x-text="selected.length"></span> items</p>
</div>
Comparison: render() vs render_alpine()
Standard render() method:
// Takes map[string]string - simple string data only
return ctx.render('products/show', {
'product_name': product.name
'product_price': product.price.str()
})
Alpine-friendly render_alpine() method:
// Takes map[string]vm.Any - complex data structures supported
return ctx.render_alpine('products/show', {
'product': vm.to_map(product) // Entire struct as map
})
When to Use render_alpine()
Use render_alpine() when:
- ✅ Your template uses Alpine.js components (
x-data,x-for, etc.) - ✅ You need to pass complex data structures (arrays, nested objects)
- ✅ You want Alpine.js version info injected automatically
Use regular render() when:
- Template doesn't use Alpine.js
- Only need simple string interpolation
- No client-side interactivity needed
Best Practices
Always convert structs to maps explicitly:
// ✅ Good - explicit conversion return ctx.render_alpine('products/show', { 'product': vm.to_map(product) }) // ❌ Bad - struct won't work directly return ctx.render_alpine('products/show', { 'product': product // Type error })Use VeeMarker built-ins in templates:
<!-- Combine render_alpine with ?alpine_json --> <div x-data='${product?alpine_json}'> <h1 x-text="name"></h1> </div>Check Alpine version when needed:
<#if _alpine_version??> <!-- Alpine.js is available, version ${_alpine_version} --> </#if>
Server Data → Client State
VeeMarker (server-side) can inject data into Alpine.js (client-side):
<!-- VeeMarker injects server data -->
<div x-data='{
id: ${product.id},
name: "${product.name?js_string}",
price: ${product.price},
inStock: ${product.in_stock?c}
}'>
<!-- Alpine uses the data -->
<h2 x-text="name"></h2>
<p>$<span x-text="price"></span></p>
<button x-show="inStock">Add to Cart</button>
</div>
Key techniques:
- Use
?js_stringto escape JavaScript strings safely - Use
?cfor booleans (outputs "true"/"false") - Numbers can be injected directly
Loops with VeeMarker + Alpine
Use VeeMarker for initial render, Alpine for interactivity:
<div x-data="{ selected: [] }">
<#list products as product>
<div>
<input
type="checkbox"
:value="${product.id}"
x-model="selected">
<label>${product.name}</label>
</div>
</#list>
<p>Selected: <span x-text="selected.length"></span> items</p>
</div>
Forms with Validation
VeeMarker renders form, Alpine adds client-side validation:
<form method="POST" action="/shorten"
x-data="{ url: '', isValid: false }"
x-init="$watch('url', value => isValid = value.startsWith('http'))"
@submit="if (!isValid) { $event.preventDefault(); alert('Invalid URL'); }">
<input
type="url"
name="url"
x-model="url"
placeholder="https://example.com"
required>
<button
type="submit"
:disabled="!isValid"
:class="{ 'btn-primary': isValid, 'btn-disabled': !isValid }">
Shorten URL
</button>
<p x-show="!isValid && url.length > 0" class="error" x-cloak>
URL must start with http:// or https://
</p>
</form>
Best Practices
1. Use VeeMarker for Initial Render
Good:
<!-- VeeMarker renders list server-side -->
<#list products as product>
<div class="product">${product.name}</div>
</#list>
Avoid:
<!-- Don't fetch data with Alpine on page load -->
<div x-data="{ products: [] }"
x-init="fetch('/api/products').then(r => r.json()).then(data => products = data)">
<template x-for="product in products">
<div x-text="product.name"></div>
</template>
</div>
Why: Server-side rendering is faster and better for SEO.
2. Keep Alpine for Interactivity
Use Alpine for user interactions, not data fetching:
Good use cases:
- Toggle visibility
- Form validation
- Copy to clipboard
- Dropdown menus
- Modal dialogs
- Search filtering (on already-loaded data)
Poor use cases:
- Fetching initial page data
- Complex business logic
- Database queries
3. Escape Data Properly
When injecting server data into Alpine:
<!-- ✅ GOOD - Escaped -->
<div x-data='{ name: "${user.name?js_string}" }'>
<!-- ❌ BAD - Not escaped, XSS risk -->
<div x-data='{ name: "${user.name}" }'>
4. Use x-cloak for Flicker Prevention
<!-- Without x-cloak -->
<div x-show="open">
<!-- User sees this briefly before Alpine hides it -->
</div>
<!-- With x-cloak -->
<div x-show="open" x-cloak>
<!-- Hidden until Alpine is ready -->
</div>
5. Combine with Method Override
Alpine + Varel's method override for HTML forms:
<form method="POST" action="/products/${product.id}"
x-data="{ deleting: false }"
@submit="deleting = true">
<input type="hidden" name="_method" value="DELETE">
<button type="submit" :disabled="deleting">
<span x-show="!deleting">Delete</span>
<span x-show="deleting" x-cloak>Deleting...</span>
</button>
</form>
Common Patterns
Search/Filter (Client-Side)
Filter already-loaded data:
<div x-data="{
search: '',
get filteredProducts() {
return this.products.filter(p =>
p.name.toLowerCase().includes(this.search.toLowerCase())
)
},
products: [
<#list products as product>
{ id: ${product.id}, name: "${product.name?js_string}" }<#sep>,</#sep>
</#list>
]
}">
<input x-model="search" placeholder="Search products...">
<template x-for="product in filteredProducts" :key="product.id">
<div x-text="product.name"></div>
</template>
<p x-show="filteredProducts.length === 0" x-cloak>
No products found
</p>
</div>
Pagination (Client-Side)
Paginate pre-loaded data:
<div x-data="{
page: 1,
perPage: 10,
get paginatedItems() {
const start = (this.page - 1) * this.perPage;
return this.items.slice(start, start + this.perPage);
},
get totalPages() {
return Math.ceil(this.items.length / this.perPage);
},
items: [
<#list items as item>
{ id: ${item.id}, name: "${item.name?js_string}" }<#sep>,</#sep>
</#list>
]
}">
<template x-for="item in paginatedItems" :key="item.id">
<div x-text="item.name"></div>
</template>
<div class="pagination">
<button @click="page--" :disabled="page === 1">Previous</button>
<span x-text="`Page ${page} of ${totalPages}`"></span>
<button @click="page++" :disabled="page === totalPages">Next</button>
</div>
</div>
AJAX Form Submission
Submit form via AJAX with Alpine:
<form
x-data="{ submitting: false, result: null }"
@submit.prevent="
submitting = true;
fetch($el.action, {
method: 'POST',
body: new FormData($el)
})
.then(r => r.json())
.then(data => { result = data; submitting = false; })
.catch(err => { alert('Error'); submitting = false; })
"
action="/api/shorten"
method="POST">
<input name="url" required>
<button type="submit" :disabled="submitting">
<span x-show="!submitting">Shorten</span>
<span x-show="submitting" x-cloak>Shortening...</span>
</button>
<div x-show="result" x-cloak>
<p>Short URL: <code x-text="result.short_url"></code></p>
</div>
</form>
Summary
You've learned:
✅ How Alpine.js adds interactivity to Varel apps ✅ Core Alpine.js directives (x-data, x-show, @click, x-model) ✅ Using pre-built components (copyButton, confirmDelete, toast, etc.) ✅ Integrating Alpine with VeeMarker templates ✅ Best practices (server-render first, escape data, use x-cloak) ✅ Common patterns (search/filter, pagination, AJAX forms)
Continue to the Production Features Guide for deployment!
Additional Resources
- Alpine.js Documentation: https://alpinejs.dev
- VeeMarker Documentation:
/home/ctusa/repos/veemarker/docs/veemarker.md - Component Source:
views/shared/alpine_components.vtpl