Declarative backend programming. SQL as first-class citizen, HTML as native output, htmx-aware. 27 keywords. Single binary. Zero config.
config database: env DATABASE_URL default "sqlite://app.db" port: env PORT default 8080 secret: env SECRET_KEY required model user name: text required email: email unique password: password required role: option [admin, user] default user created: timestamp auto model task title: text required done: bool default false owner: user required created: timestamp auto auth table: user identity: email password: password login: /login after login: /tasks page /tasks requires auth query tasks: SELECT id, title, done FROM task WHERE owner = :current_user.id ORDER BY created DESC paginate 20 html <h1>My Tasks</h1> {{each tasks}} <div> <span>{title}</span> <button hx-post="/tasks/{id}/toggle">Toggle</button> </div> {{end}} action /tasks/create method POST requires auth validate task query: INSERT INTO task (title, owner) VALUES (:title, :current_user.id) redirect /tasks
Before & After
A task manager with auth, CRUD, and real-time updates. One requires a stack. The other requires Kilnx.
"express", "prisma", "@prisma/client", "ejs", "passport", "passport-local", "express-session", "csurf", "bcrypt", "express-validator", "connect-flash" // 11 dependencies
model Task { id Int @id @default(autoincrement()) title String done Boolean @default(false) createdAt DateTime @default(now()) userId Int user User @relation(...) }
const router = express.Router() router.get('/', ensureAuth, async (req, res) => { const tasks = await prisma.task.findMany({ where: { userId: req.user.id }, orderBy: { createdAt: 'desc' } }) res.render('tasks/index', { tasks }) }) // + POST, PUT, DELETE handlers // + validation middleware // + error handling
config database: env DATABASE_URL default "sqlite://app.db" port: 8080 secret: env SECRET_KEY required model task title: text required done: bool default false owner: user required created: timestamp auto auth table: user identity: email password: password login: /login after login: /tasks page /tasks requires auth query tasks: SELECT id, title, done FROM task WHERE owner = :current_user.id ORDER BY created DESC paginate 20 html <h1>My Tasks</h1> {{each tasks}} <div> <span>{title}</span> <button hx-post="/tasks/{id}/toggle">Toggle</button> </div> {{end}} action /tasks/create method POST requires auth validate task query: INSERT INTO task (title, owner) VALUES (:title, :current_user.id) redirect /tasks
Dependencies
Files
Lines of code
Config files
Features
27 keywords replace an entire stack. Models, pages, actions, auth, jobs, emails, websockets, API endpoints.
No ORM, no query builder. Write SQL inline with parameter binding. JOINs, aggregates, subqueries. Your database is a feature, not an afterthought.
HTML is the return type. Pages, fragments, and layouts compose naturally with {{each}}, {{if}}, and {field} interpolation. No template engine bolted on.
Fragments, SSE streams, WebSockets, and broadcast are built-in keywords. Partial HTML updates without writing a single line of JavaScript.
Compiles to one ~15MB executable with SQLite, bcrypt, and htmx.js embedded. No runtime, no dependencies, no node_modules. Copy and run.
CSRF tokens auto-injected in forms. SQL injection prevented by parameterized queries. XSS blocked by auto-escaping. Bcrypt password hashing built-in.
No framework to choose, no ORM to configure, no template engine to wire up. Create a .kilnx file, run it. One file can be a complete app.
Real Examples
These are real, runnable examples. Not pseudocode.
Copy, paste, kilnx run app.kilnx, done.
page / "Hello World"
No imports, no boilerplate, no configuration. A running HTTP server with htmx auto-linked.
$ kilnx run hello.kilnx Server running on http://localhost:8080
config database: env DATABASE_URL default "sqlite://blog.db" port: env PORT default 8080 secret: env SECRET_KEY default "dev-secret" model user name: text required min 2 max 100 email: email unique password: password required role: option [admin, author] default author created: timestamp auto model post title: text required min 5 body: richtext required status: option [draft, published] default draft author: user required created: timestamp auto auth table: user identity: email password: password login: /login after login: /posts page / query posts: SELECT p.title, p.created, u.name as author FROM post p LEFT JOIN user u ON u.id = p.author WHERE p.status = 'published' ORDER BY p.created DESC paginate 10 html <h1>Blog</h1> {{each posts}} <article> <h2>{title}</h2> <span>by {author} on {created}</span> </article> {{end}} page /posts requires auth query posts: SELECT id, title, status, created FROM post WHERE author = :current_user.id ORDER BY created DESC html <h1>My Posts</h1> <a href="/posts/new">New Post</a> {{each posts}} <div> <a href="/posts/{id}">{title}</a> <span>{status}</span> </div> {{end}} action /posts/create method POST requires auth validate title: required min 5 body: required query: INSERT INTO post (title, body, author, status) VALUES (:title, :body, :current_user.id, 'published') redirect /posts action /posts/:id/delete method POST requires auth query: DELETE FROM post WHERE id = :id AND author = :current_user.id redirect /posts fragment /posts/:id/preview query post: SELECT title, body, created FROM post WHERE id = :id html <article> <h2>{post.title}</h2> <p>{post.body}</p> <time>{post.created}</time> </article> test "homepage loads" visit / expect page / contains "Blog" test "create post requires auth" visit /posts/new expect page /login
# Models with relationships model company name: text required min 2 max 100 industry: text website: text created_at: timestamp auto model contact name: text required min 2 max 100 email: email unique phone: phone role: text company: company required created_at: timestamp auto model deal title: text required value: float required stage: option [lead, proposal, negotiation, closed_won, closed_lost] default lead contact: contact required notes: richtext created_at: timestamp auto # Dashboard with aggregate queries page / layout main title "Dashboard" query stats: SELECT (SELECT COUNT(*) FROM contact) as contacts, (SELECT COUNT(*) FROM company) as companies, (SELECT COUNT(*) FROM deal WHERE stage NOT IN ('closed_won','closed_lost')) as active_deals, (SELECT COALESCE(SUM(value),0) FROM deal WHERE stage = 'closed_won') as revenue query recent_deals: SELECT d.title, d.value, d.stage, c.name as contact FROM deal d LEFT JOIN contact c ON c.id = d.contact ORDER BY d.created_at DESC LIMIT 5 html <div class="grid grid-cols-4 gap-4"> <div class="stat-card">{stats.contacts} Contacts</div> <div class="stat-card">{stats.companies} Companies</div> <div class="stat-card">{stats.active_deals} Active Deals</div> <div class="stat-card">${stats.revenue} Revenue</div> </div> {{each recent_deals}} <tr> <td>{title}</td> <td>{contact}</td> <td>${value}</td> <td><span class="badge-{stage}">{stage}</span></td> </tr> {{end}} # CRUD actions with validation and htmx action /contacts/create method POST validate name: required min 2 email: required, is email query: INSERT INTO contact (name, email, phone, role, company) VALUES (:name, :email, :phone, :role, :company) redirect /contacts action /contacts/:id/delete method POST query: DELETE FROM contact WHERE id = :id redirect /contacts
The full CRM example is ~800 lines and includes layout with Tailwind CSS, sidebar navigation, deal pipeline, search, and responsive design. See the complete source on GitHub.
The entire language
Python has 35 keywords and does none of this without importing libraries. JavaScript has 64. Java has 67. Kilnx has 27 and delivers a complete web app from database to browser.
Click a keyword to explore the language.
Application-level settings. Database, port, secrets, environment variables.
config database: env DATABASE_URL default "sqlite://app.db" port: env PORT default 8080 secret: env SECRET_KEY required uploads: ./uploads max 50mb
Single source of truth. Defines types once, generates CREATE TABLE, validation, and migration.
model user name: text required min 2 max 100 email: email unique role: option [admin, editor, viewer] default viewer active: bool default true created: timestamp auto
Role-based access control. Declare who can read and write, with conditions.
permissions admin: all editor: read post, write post where author = current_user viewer: read post where status = published
Complete authentication in 6 lines. Registration, login, bcrypt hashing, session cookies.
auth table: user identity: email password: password login: /login after login: /dashboard
Page wrapper template. Four placeholders: {page.title}, {page.content}, {nav}, {kilnx.js}.
layout main html <html> <head> <title>{page.title}</title> {kilnx.js} </head> <body> {nav} {page.content} </body> </html>
GET route returning full HTML. Queries data, renders templates, supports auth and layouts.
page /users layout main title "Users" query users: SELECT name, email FROM user html {{each users}} <div class="user"> <strong>{name}</strong> <span>{email}</span> </div> {{end}}
POST/PUT/DELETE route for mutations. Validate, query, redirect or respond with fragments.
action /users/:id/archive method POST requires auth query: UPDATE users SET archived = true WHERE id = :id respond fragment user-card with query: SELECT name, email FROM users WHERE id = :id
Partial HTML for htmx swaps. First-class fragments without a full page wrapper.
fragment /users/:id/card query user: SELECT name, email FROM users WHERE id = :id html <div class="card"> <h3>{user.name}</h3> <p>{user.email}</p> </div>
JSON endpoint. Same grammar as page, returns JSON with automatic pagination metadata.
api /api/v1/posts requires auth query posts: SELECT id, title, status, created FROM post WHERE status = 'published' ORDER BY created DESC paginate 50
Server-Sent Events for realtime updates. Polls a SQL query and pushes results.
stream /notifications requires auth query: SELECT message, created_at FROM notifications WHERE user_id = :current_user.id AND seen = false every 5s
Bidirectional WebSocket with rooms, history, and broadcast.
socket /chat/:room requires auth on connect query: SELECT message, author FROM chat_message WHERE room = :room ORDER BY created DESC LIMIT 50 send history on message validate body: required max 500 query: INSERT INTO chat_message (body, author, room) VALUES (:body, :current_user.id, :room) broadcast to :room fragment chat-bubble
Receive external events with HMAC signature verification. Stripe, GitHub, any provider.
webhook /stripe/payment secret env STRIPE_SECRET on event payment_intent.succeeded query: UPDATE order SET status = 'paid' WHERE stripe_id = :event.id send email to :event.customer_email subject: "Payment confirmed"
Timed background tasks running inside the same binary. No Redis, no Celery.
schedule cleanup every 24h query: DELETE FROM session WHERE expires_at < datetime('now') schedule report every monday at 9:00 query stats: SELECT count(*) as new_users FROM user WHERE created > datetime('now', '-7 days') send email to query: SELECT email FROM user WHERE role = 'admin' template: weekly-report subject: "Weekly: {stats.new_users} new users"
Async background work with automatic retry and exponential backoff.
job generate-report query data: SELECT * FROM order WHERE created > :start_date AND created < :end_date generate pdf from template report with data send email to :requested_by template: report-ready attach: generated pdf subject: "Your report is ready"
Inline SQL. Named or anonymous, single or multi-line. Parameter binding with :param.
page /dashboard requires auth query stats: SELECT count(*) as total FROM orders "Welcome back. You have {stats.total} orders."
Named query definitions. Define once, reference by name across pages and actions.
queries active-users: SELECT u.name, u.email, COUNT(o.id) as orders FROM users u LEFT JOIN orders o ON o.user_id = u.id WHERE u.active = true GROUP BY u.id page /users query users: active-users
Declarative server-side validation. By model name or inline rules.
action /users/new method POST validate name: required email: required, is email age: min 18, max 120 query: INSERT INTO users (name, email, age) VALUES (:name, :email, :age) redirect /users
Automatic pagination. Generates controls with htmx. Add "paginate N" to any query.
page /posts query posts: SELECT title, author FROM post WHERE status = 'published' ORDER BY published_at DESC paginate 20
Declarative SMTP email with templates, variables, and attachments.
action /users/invite method POST requires admin validate email: required, is email query: INSERT INTO user (email, role, active) VALUES (:email, 'viewer', false) send email to :email template: invite subject: "You've been invited"
HTTP redirect after an action. Detects htmx requests and sends HX-Redirect header.
action /posts/create method POST requires auth validate post query: INSERT INTO post (title, body, author) VALUES (:title, :body, :current_user.id) redirect /posts
Result branching. Handle success, error, not found, or forbidden conditions.
action /users/:id/delete method POST requires auth query: DELETE FROM users WHERE id = :id on success: redirect /users on error: alert "Could not delete user" on forbidden: redirect /login
Rate limiting per user or per IP. Protect endpoints from abuse.
limit /api/* requests: 100 per minute per user on exceeded: status 429 message "Too many requests" limit /login requests: 5 per minute per ip on exceeded: status 429 message "Too many attempts" delay 30s
Observability built in. Slow query detection, request logging, error traces.
log level: env LOG_LEVEL default info queries: slow > 100ms requests: all errors: all with stacktrace
Declarative tests in the same language, same file. No Selenium, no Cypress.
test "user can create post" as user with role editor visit /posts/new fill title with "Test Post" fill body with "Content here" submit expect page /posts contains "Test Post" expect query: SELECT count(*) FROM post WHERE title = 'Test Post' returns 1
i18n built into the language. Define translations, use {t.key} in templates.
translations en welcome: "Welcome back" users: "Users" pt welcome: "Bem vindo de volta" users: "Usuarios" page /dashboard requires auth "{t.welcome}, {current_user.name}"
Dispatch an async job to the background queue. Fire-and-forget from actions.
action /reports/generate method POST requires admin validate start_date: required, is date end_date: required, is date enqueue generate-report with start_date: :start_date end_date: :end_date requested_by: :current_user.email respond fragment ".reports" with alert success "Report is being generated"
Send a rendered fragment to all WebSocket clients in a room.
socket /chat/:room requires auth on message query: INSERT INTO chat_message (body, author, room) VALUES (:body, :current_user.id, :room) broadcast to :room fragment chat-bubble
How it compares
For the 80% use case (forms, dashboards, CRUDs with auth), Kilnx does more with less.
| Kilnx | Django | Rails | Next.js | Phoenix | |
|---|---|---|---|---|---|
| Dependencies | 0 | 50+ | 30+ | 100+ | 20+ |
| Blog with auth (LOC) | 94 | ~500 | ~400 | ~1000 | ~350 |
| Compiled binary | Yes (~15MB) | No | No | No | Yes (BEAM) |
| Built-in auth | 6 lines | Yes | Devise gem | No | phx.gen.auth |
| Built-in jobs | Yes | Celery | Sidekiq | No | Oban |
| Built-in testing | Same file | Django test | RSpec | Jest/Vitest | ExUnit |
| Realtime (SSE/WS) | Built-in | Channels | ActionCable | Manual | LiveView |
| Learning time | ~1 hour | ~40 hours | ~40 hours | ~30 hours | ~50 hours |
Philosophy
htmx completed HTML. Kilnx completes htmx. Every design decision serves a single goal: make the backend as simple as the problem demands, not as complex as the tools allow.
Get Started
$ go install github.com/kilnx-org/kilnx@latest
page / "Hello World"
$ kilnx run app.kilnx Server running on http://localhost:8080
kilnx run <file>
Development server with hot reload
kilnx build <file> -o myapp
Compile to standalone binary
kilnx test <file>
Run declarative tests
kilnx check <file>
Static analysis and security scan
kilnx migrate <file>
Apply database migrations from models
kilnx lsp
Language server for VS Code and Neovim
$ kilnx build app.kilnx -o server $ scp server you@host:~/ $ ssh you@host "./server"
FROM ghcr.io/kilnx-org/kilnx AS build COPY app.kilnx . RUN kilnx build app.kilnx -o /server FROM alpine:3.21 COPY --from=build /server /usr/local/bin/ CMD ["server"]
# Just push. PORT is auto-detected. $ git push railway main $ flyctl deploy $ render deploy