examples.md
Examples & Recipes
Practical patterns for building with ReadySite.
Minimal Application
package main
import (
"embed"
"github.com/readysite/readysite/pkg/application"
)
//go:embed views
var views embed.FS
func main() {
application.Serve(views,
application.WithController(Home()),
)
}
func Home() (string, *HomeController) {
return "home", &HomeController{}
}
type HomeController struct {
application.BaseController
}
func (c *HomeController) Setup(app *application.App) {
c.BaseController.Setup(app)
http.Handle("GET /", app.Serve("index.html", nil))
}
func (c HomeController) Handle(r *http.Request) application.Controller {
c.Request = r
return &c
}
func (c *HomeController) Message() string {
return "Hello, World!"
}
<!-- views/layouts/base.html -->
<!DOCTYPE html>
<html><body>{{block "content" .}}{{end}}</body></html>
<!-- views/index.html -->
{{template "base.html" .}}
{{define "content"}}
<h1>{{home.Message}}</h1>
{{end}}
Blog with Collections
Models
// models/db.go
package models
import (
"github.com/readysite/readysite/pkg/database"
"github.com/readysite/readysite/pkg/database/engines"
)
var (
DB = engines.NewAuto()
Posts = database.Manage(DB, new(Post),
database.WithIndex[Post]("Published"),
)
)
// models/post.go
type Post struct {
database.Model
Title string
Slug string
Content string
Published bool
}
Controller
func Blog() (string, *BlogController) {
return "blog", &BlogController{}
}
type BlogController struct {
application.BaseController
}
func (c *BlogController) Setup(app *application.App) {
c.BaseController.Setup(app)
http.Handle("GET /blog", app.Serve("blog/index.html", nil))
http.Handle("GET /blog/{slug}", app.Serve("blog/post.html", nil))
http.Handle("POST /blog", app.Method(c, "Create", RequireAuth))
}
func (c BlogController) Handle(r *http.Request) application.Controller {
c.Request = r
return &c
}
// Template: {{blog.Posts}}
func (c *BlogController) Posts() []*models.Post {
posts, _ := models.Posts.Search("WHERE Published = ? ORDER BY CreatedAt DESC", true)
return posts
}
// Template: {{blog.Post}}
func (c *BlogController) Post() *models.Post {
slug := c.PathValue("slug")
post, _ := models.Posts.First("WHERE Slug = ? AND Published = ?", slug, true)
return post
}
func (c *BlogController) Create(w http.ResponseWriter, r *http.Request) {
post := &models.Post{
Title: r.FormValue("title"),
Slug: r.FormValue("slug"),
Content: r.FormValue("content"),
Published: r.FormValue("published") == "on",
}
if _, err := models.Posts.Insert(post); err != nil {
c.RenderError(w, r, err)
return
}
c.Redirect(w, r, "/blog/"+post.Slug)
}
Templates
<!-- views/blog/index.html -->
{{template "base.html" .}}
{{define "content"}}
<h1>Blog</h1>
{{range $post := blog.Posts}}
<article>
<h2><a href="/blog/{{$post.Slug}}">{{$post.Title}}</a></h2>
<time>{{$post.CreatedAt.Format "Jan 2, 2006"}}</time>
</article>
{{else}}
<p>No posts yet.</p>
{{end}}
{{end}}
<!-- views/blog/post.html -->
{{template "base.html" .}}
{{define "content"}}
{{with blog.Post}}
<article>
<h1>{{.Title}}</h1>
<div>{{.Content}}</div>
</article>
{{end}}
{{end}}
Authentication with Bouncers
Route Guard
func RequireAuth(app *application.App, w http.ResponseWriter, r *http.Request) bool {
user := GetCurrentUser(r)
if user == nil {
http.Redirect(w, r, "/signin?next="+r.URL.Path, http.StatusSeeOther)
return false
}
return true
}
func RequireAdmin(app *application.App, w http.ResponseWriter, r *http.Request) bool {
user := GetCurrentUser(r)
if user == nil || user.Role != "admin" {
http.Error(w, "Forbidden", http.StatusForbidden)
return false
}
return true
}
Protected Routes
func (c *AdminController) Setup(app *application.App) {
c.BaseController.Setup(app)
// Public route
http.Handle("GET /signin", app.Serve("auth/signin.html", nil))
// Requires authentication
http.Handle("GET /dashboard", app.Serve("dashboard.html", RequireAuth))
// Requires admin role
http.Handle("GET /admin/users", app.Serve("admin/users.html", RequireAdmin))
http.Handle("DELETE /admin/users/{id}", app.Method(c, "DeleteUser", RequireAdmin))
}
REST API Endpoint
func API() (string, *APIController) {
return "api", &APIController{}
}
type APIController struct {
application.BaseController
}
func (c *APIController) Setup(app *application.App) {
c.BaseController.Setup(app)
http.Handle("GET /api/posts", app.Method(c, "List", nil))
http.Handle("GET /api/posts/{id}", app.Method(c, "Get", nil))
http.Handle("POST /api/posts", app.Method(c, "Create", RequireAuth))
}
func (c APIController) Handle(r *http.Request) application.Controller {
c.Request = r
return &c
}
func (c *APIController) List(w http.ResponseWriter, r *http.Request) {
posts, err := models.Posts.All()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(posts)
}
func (c *APIController) Get(w http.ResponseWriter, r *http.Request) {
post, err := models.Posts.Get(r.PathValue("id"))
if err != nil {
http.Error(w, "Not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(post)
}
func (c *APIController) Create(w http.ResponseWriter, r *http.Request) {
var post models.Post
if err := json.NewDecoder(r.Body).Decode(&post); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if _, err := models.Posts.Insert(&post); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(post)
}
Real-Time Updates with SSE
Server
func (c *FeedController) Setup(app *application.App) {
c.BaseController.Setup(app)
http.Handle("GET /feed", app.Serve("feed.html", nil))
http.Handle("GET /api/feed/stream", app.Method(c, "Stream", nil))
http.Handle("POST /api/feed/post", app.Method(c, "Post", RequireAuth))
}
var broadcast = make(chan *models.Post, 100)
func (c *FeedController) Stream(w http.ResponseWriter, r *http.Request) {
stream := c.Stream(w)
for {
select {
case <-r.Context().Done():
return
case post := <-broadcast:
stream.Render("new-post", "partials/post-card.html", post)
}
}
}
func (c *FeedController) Post(w http.ResponseWriter, r *http.Request) {
post := &models.Post{
Title: r.FormValue("title"),
Content: r.FormValue("content"),
}
models.Posts.Insert(post)
broadcast <- post
c.Refresh(w, r)
}
Client
<!-- views/feed.html -->
{{template "base.html" .}}
{{define "content"}}
<div hx-ext="sse" sse-connect="/api/feed/stream">
<div id="feed" sse-swap="new-post" hx-swap="afterbegin">
<!-- New posts appear here -->
</div>
</div>
<form hx-post="/api/feed/post" hx-swap="none">
<input name="title" placeholder="Title" required>
<textarea name="content" placeholder="What's on your mind?"></textarea>
<button type="submit">Post</button>
</form>
{{end}}
<!-- views/partials/post-card.html -->
<div class="card">
<h3>{{.Title}}</h3>
<p>{{.Content}}</p>
</div>
React Island
For rich interactivity, use React islands alongside HTMX:
Setup
// main.go
application.Serve(views,
frontend.WithBundler(&esbuild.Config{
Entry: "frontend/index.js",
Include: []string{"frontend"},
}),
application.WithController(Home()),
)
Component
// frontend/components/Counter.jsx
import React, { useState } from 'react'
export function Counter({ initial = 0 }) {
const [count, setCount] = useState(initial)
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(c => c + 1)}>+</button>
<button onClick={() => setCount(c => c - 1)}>-</button>
</div>
)
}
Registration
// frontend/index.js
import * as React from 'react'
import * as ReactDOM from 'react-dom/client'
import { Counter } from './components/Counter'
// Render function - mounts a component to an element
export function render(el, Component, props) {
const root = ReactDOM.createRoot(el)
root.render(React.createElement(Component, props))
el._root = root
}
// Unmount function - unmounts a component from an element
export function unmount(el) {
if (el._root) {
el._root.unmount()
delete el._root
}
}
// Export components
export { Counter }
Usage in Template
{{render "Counter" (dict "initial" 5)}}
Use HTMX for navigation and forms. Use React islands for complex interactive components like editors, charts, and dashboards.
Pagination
func (c *BlogController) Posts() []*models.Post {
page, _ := strconv.Atoi(c.QueryParam("page", "1"))
limit := 10
offset := (page - 1) * limit
posts, _ := models.Posts.Search(
"WHERE Published = ? ORDER BY CreatedAt DESC LIMIT ? OFFSET ?",
true, limit, offset,
)
return posts
}
func (c *BlogController) PageCount() int {
count, _ := models.Posts.Count("WHERE Published = ?", true)
return (count + 9) / 10 // ceil division
}
{{range $i := seq 1 blog.PageCount}}
<a href="/blog?page={{$i}}" class="btn btn-sm">{{$i}}</a>
{{end}}
Email Notifications
// main.go
//go:embed all:emails
var emails embed.FS
func main() {
var emailer application.Emailer
if key := os.Getenv("RESEND_API_KEY"); key != "" {
emailer = emailers.NewResend(emails, key, "App <noreply@example.com>", nil)
} else {
emailer = emailers.NewLogger(emails, nil) // Logs to stdout in dev
}
application.Serve(views,
application.WithEmailer(emailer),
)
}
// In a controller
c.App.Emailer.Send(user.Email, "Welcome!", "welcome.html", map[string]any{
"user": user,
"year": time.Now().Year(),
})
<!-- emails/welcome.html -->
<h1>Welcome, {{.user.Name}}!</h1>
<p>Thanks for signing up.</p>