Manual Getting Started At a Glance

At a Glance

elements man start/at-a-glance Read as markdown

One page of the whole framework: a route, a template, an rpc, a job, a LiveTable, a test.

A small real-time comment app. Most of Elements in one place: html templates, a route, a migration, and a LiveTable.

index.ts

import { App } from "@elements/app";
import config from "#config";
import home from "#app/pages/home";

const app = new App();

app.route("/", home);

app.start(config);

app/pages/home/models.ts

export interface Comment {
  id: string;
  text: string;
  selected?: boolean;
}

app/migrations/20260509120000-add-comments-table.migration.sql

Generated by elements create migration "add comments table" -tables=comments. The -tables flag scaffolds the canonical baseline (id, createdAt, updatedAt, and the touchUpdatedAt trigger) for each named table. Pass -tables=comments,users to scaffold several at once.

create or replace function touchUpdatedAt()
returns trigger
language plpgsql
as $$
begin
  new.updatedAt = now();
  return new;
end;
$$;

create table comments (
  id uuid primary key default uuidGenerateV7(),
  text text not null,
  createdAt timestamptz not null default now(),
  updatedAt timestamptz not null default now()
);

create trigger commentsTouchUpdatedAt
  before update on comments
  for each row execute function touchUpdatedAt();

id is UUIDv7: time-sortable, so newer rows sort after older ones without a separate timestamp index. Columns are camelCase in the migration. Elements converts to snake_case at the database boundary and back to camelCase on results.

Save the file and the project server applies the migration to the development database. Edit the file (add a column, an index, a constraint) and the project server rolls back and re-applies. Once a migration has run on staging or production it's frozen and never runs again. Agents don't run elements db migrate or touch the database directly.

app/pages/home/index.ts

import { Request, Response, LiveTable } from "@elements/app";
import { Comment } from "./models";
import html from "./template";

// LiveTable for the `comments` table, inferred from the variable name.
// Wires up select/insert/update/delete rpc and optimistic browser-side
// mutations. Realtime is on by default, so every watching browser sees
// new rows live over Postgres NOTIFY/LISTEN. Pass `realtime: false` for
// a snapshot read.
const comments = new LiveTable<Comment>();

export default function route(req: Request, res: Response) {
  return new html({
    comments,
    mode: req.params.mode ?? "light",
  });
}

app/pages/home/template.html

import { Layout } from "#app/shared/templates/layout";
import { Comment } from "./models";
import { LiveTable } from "@elements/app";
import "./style.css";

// event handlers

function onAddComment(form: { text: string }, comments: LiveTable<Comment>) {
  comments.insert({ text: form.text }, () => form.text = "");
}

// templates

<html (comments: LiveTable<Comment>, mode: string = "dark") class="home">
  <Layout mode={mode}>
    <CommentList>
      <CommentItem e:for={comment of comments} {comment} />
    </CommentList>
    <CommentAdd {comments} />
  </Layout>
</html>

<CommentList>
  <ul class="comment-list">
    <slot/>
  </ul>
</CommentList>

<CommentItem (comment: Comment)>
  <li tabindex={0}
      class={["comment-item", comment.selected && "comment-selected"]}
      onfocus={() => comment.selected = true}
      onblur={() => comment.selected = false}>
    {comment.text}
  </li>
</CommentItem>

<CommentAdd (comments: LiveTable<Comment>, private form: { text: string } = { text: "" })>
  <form class="comment-add" onsubmit={() => onAddComment(form, comments)}>
    <input value={form.text} placeholder="Write a comment..." />
    <button type="submit">Post</button>
  </form>
</CommentAdd>

This one example covers: App and app.route(), a migration with id/camelCase/trigger, a page route handler returning new html({...}), a LiveTable with select/insert/update/delete and realtime, template attributes with private and defaults, sub-templates and <slot/>, e:for (keyed by id), event handlers, two-way form binding with value=, reactive class arrays, and lifecycle-style reactivity via mutating comment.selected.