Retool vs Appsmith vs ToolJet: Internal Tool Builders (2026)
TL;DR
Retool is the fastest way to build internal tools — drag-and-drop components, SQL/API queries, JavaScript transformers, workflows, mobile apps, the most mature low-code platform. Appsmith is the open-source internal tool builder — drag-and-drop, JS everywhere, Git sync, self-hosted, 45+ integrations, community-driven. ToolJet is the open-source low-code platform — visual builder, database queries, REST/GraphQL, workflows, multi-environment, self-hosted or cloud. In 2026: Retool for the most polished experience, Appsmith for open-source with Git workflow, ToolJet for self-hosted with workflows.
Key Takeaways
- Retool: Most mature — 100+ integrations, workflows, mobile, AI
- Appsmith: 35K+ GitHub stars — open-source, Git sync, JS-first
- ToolJet: 33K+ GitHub stars — open-source, workflows, multi-env
- Retool has the largest component library and integration ecosystem
- Appsmith offers the best developer experience with Git-based versioning
- ToolJet provides built-in workflow automation alongside app building
Retool
Retool — build internal tools fast:
SQL queries
-- Retool query: "getPackages"
-- Data source: PostgreSQL (pkgpulse_db)
SELECT
p.id,
p.name,
p.description,
p.downloads,
p.version,
p.created_at,
a.name AS author_name,
a.email AS author_email
FROM packages p
LEFT JOIN authors a ON p.author_id = a.id
WHERE
({{ !searchInput.value }} OR p.name ILIKE '%' || {{ searchInput.value }} || '%')
AND ({{ !tagFilter.value }} OR {{ tagFilter.value }} = ANY(p.tags))
ORDER BY
CASE WHEN {{ sortSelect.value }} = 'downloads' THEN p.downloads END DESC,
CASE WHEN {{ sortSelect.value }} = 'name' THEN p.name END ASC,
CASE WHEN {{ sortSelect.value }} = 'newest' THEN p.created_at END DESC
LIMIT {{ pagination.pageSize }}
OFFSET {{ (pagination.page - 1) * pagination.pageSize }}
JavaScript transformers
// Retool transformer: "formatPackageData"
const packages = {{ getPackages.data }}
return packages.map(pkg => ({
...pkg,
downloads_formatted: new Intl.NumberFormat().format(pkg.downloads),
age: Math.floor((Date.now() - new Date(pkg.created_at)) / (1000 * 60 * 60 * 24)) + ' days',
status: pkg.downloads > 1000000 ? 'Popular' : pkg.downloads > 100000 ? 'Growing' : 'New',
status_color: pkg.downloads > 1000000 ? 'green' : pkg.downloads > 100000 ? 'yellow' : 'gray',
}))
REST API queries
// Retool query: "fetchNpmData"
// Type: REST API
// URL: https://registry.npmjs.org/{{ packageNameInput.value }}
// Transform results:
const data = {{ fetchNpmData.data }}
return {
name: data.name,
description: data.description,
latest_version: data['dist-tags'].latest,
license: data.license,
repository: data.repository?.url,
weekly_downloads: data.downloads,
maintainers: data.maintainers.map(m => m.name),
keywords: data.keywords || [],
}
Workflows (Retool Workflows)
// Retool Workflow: "dailyPackageSync"
// Trigger: Cron (every day at 6am)
// Step 1: Fetch packages from database
const packages = await retoolContext.query("getActivePackages")
// Step 2: Fetch npm data for each package
const updates = []
for (const pkg of packages.data) {
const npmData = await fetch(
`https://api.npmjs.org/downloads/point/last-week/${pkg.name}`
).then(r => r.json())
if (npmData.downloads !== pkg.downloads) {
updates.push({
id: pkg.id,
name: pkg.name,
old_downloads: pkg.downloads,
new_downloads: npmData.downloads,
})
}
}
// Step 3: Batch update database
if (updates.length > 0) {
await retoolContext.query("batchUpdateDownloads", {
updates: updates.map(u => ({ id: u.id, downloads: u.new_downloads }))
})
}
// Step 4: Send Slack notification
if (updates.length > 0) {
await retoolContext.query("sendSlackMessage", {
message: `📦 Updated ${updates.length} packages:\n${
updates.map(u => `• ${u.name}: ${u.old_downloads} → ${u.new_downloads}`).join('\n')
}`
})
}
return { updated: updates.length, packages: updates }
Custom components
// Retool Custom Component (React):
const PackageChart = ({ model, modelUpdate }) => {
const { packages, metric } = model
return (
<div style={{ padding: 16 }}>
<h3>Package {metric} Over Time</h3>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{packages.map(pkg => (
<div key={pkg.name} style={{
padding: 12,
border: '1px solid #e2e8f0',
borderRadius: 8,
minWidth: 150,
}}>
<div style={{ fontWeight: 600 }}>{pkg.name}</div>
<div style={{ fontSize: 24, color: '#3b82f6' }}>
{new Intl.NumberFormat('en', { notation: 'compact' }).format(pkg[metric])}
</div>
</div>
))}
</div>
</div>
)
}
Appsmith
Appsmith — open-source internal tools:
Self-hosted setup
# Docker Compose:
curl -L https://bit.ly/docker-compose-CE -o docker-compose.yml
docker compose up -d
# Or Kubernetes:
helm repo add appsmith https://helm.appsmith.com
helm install appsmith appsmith/appsmith
Database queries
-- Appsmith query: "GetPackages"
-- Datasource: PostgreSQL
SELECT
p.id,
p.name,
p.description,
p.downloads,
p.version,
p.tags,
a.name AS author_name
FROM packages p
LEFT JOIN authors a ON p.author_id = a.id
WHERE
CASE
WHEN {{SearchInput.text}} != ''
THEN p.name ILIKE '%' || {{SearchInput.text}} || '%'
ELSE TRUE
END
ORDER BY p.downloads DESC
LIMIT {{Table1.pageSize}}
OFFSET {{Table1.pageOffset}}
JavaScript objects (JS Objects)
// Appsmith JS Object: "PackageUtils"
export default {
// Format package data for display:
formatPackages() {
return GetPackages.data.map(pkg => ({
...pkg,
downloads_display: this.formatNumber(pkg.downloads),
health_score: this.calculateHealth(pkg),
tags_display: pkg.tags?.join(", ") || "—",
}))
},
formatNumber(num) {
if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`
if (num >= 1000) return `${(num / 1000).toFixed(1)}K`
return String(num)
},
calculateHealth(pkg) {
let score = 0
if (pkg.downloads > 1000000) score += 40
else if (pkg.downloads > 100000) score += 25
else if (pkg.downloads > 10000) score += 10
if (pkg.version?.startsWith("1.") || parseInt(pkg.version) >= 1) score += 20
if (pkg.description?.length > 50) score += 10
if (pkg.tags?.length > 0) score += 10
if (pkg.author_name) score += 20
return Math.min(score, 100)
},
// Create new package:
async createPackage() {
const data = {
name: NameInput.text,
description: DescriptionInput.text,
version: VersionInput.text,
tags: TagsInput.text.split(",").map(t => t.trim()),
}
await CreatePackageQuery.run({ data })
await GetPackages.run()
showAlert("Package created!", "success")
closeModal("CreatePackageModal")
},
// Bulk update from CSV:
async bulkImport() {
const csvData = FilePicker1.files[0]?.data
if (!csvData) {
showAlert("Please select a CSV file", "warning")
return
}
const rows = this.parseCSV(csvData)
let imported = 0
for (const row of rows) {
try {
await UpsertPackageQuery.run({
name: row.name,
description: row.description,
version: row.version,
downloads: parseInt(row.downloads) || 0,
})
imported++
} catch (e) {
console.error(`Failed to import ${row.name}:`, e)
}
}
await GetPackages.run()
showAlert(`Imported ${imported}/${rows.length} packages`, "success")
},
parseCSV(text) {
const [header, ...lines] = text.split("\n")
const keys = header.split(",").map(k => k.trim())
return lines
.filter(line => line.trim())
.map(line => {
const values = line.split(",")
return Object.fromEntries(keys.map((k, i) => [k, values[i]?.trim()]))
})
},
}
API integration
// Appsmith API query: "FetchNpmStats"
// URL: https://api.npmjs.org/downloads/range/last-month/{{Table1.selectedRow.name}}
// Method: GET
// Transform:
const data = FetchNpmStats.data
const downloads = data.downloads || []
return {
labels: downloads.map(d => d.day),
values: downloads.map(d => d.downloads),
total: downloads.reduce((sum, d) => sum + d.downloads, 0),
average: Math.round(
downloads.reduce((sum, d) => sum + d.downloads, 0) / downloads.length
),
}
Git sync
# Appsmith Git sync — version control your apps:
# 1. Connect to Git repo from Appsmith UI
# 2. Auto-commits on publish
# 3. Branch-based development
# App structure in Git:
# my-app/
# ├── pages/
# │ ├── PackageList/
# │ │ ├── canvas.json
# │ │ ├── queries/
# │ │ │ ├── GetPackages.json
# │ │ │ └── CreatePackage.json
# │ │ └── jsobjects/
# │ │ └── PackageUtils.json
# │ └── Dashboard/
# │ ├── canvas.json
# │ └── queries/
# ├── datasources/
# │ └── PostgreSQL.json
# └── theme.json
ToolJet
ToolJet — open-source low-code platform:
Self-hosted setup
# Docker:
docker run -d \
--name tooljet \
-p 80:80 \
-e TOOLJET_DB_URL=postgresql://user:pass@host:5432/tooljet \
-e SECRET_KEY_BASE=$(openssl rand -hex 64) \
-e LOCKBOX_MASTER_KEY=$(openssl rand -hex 32) \
tooljet/tooljet:latest
# Docker Compose:
curl -LO https://tooljet-deployments.s3.us-west-1.amazonaws.com/docker/docker-compose.yaml
docker compose up -d
Database queries
-- ToolJet query: "listPackages"
-- Data source: PostgreSQL
SELECT
p.id,
p.name,
p.description,
p.downloads,
p.version,
p.tags,
p.created_at,
a.name AS author_name,
a.email AS author_email
FROM packages p
LEFT JOIN authors a ON p.author_id = a.id
WHERE
({{components.searchInput.value}} = '' OR p.name ILIKE '%' || {{components.searchInput.value}} || '%')
AND (
{{components.statusFilter.value}} = 'all'
OR ({{components.statusFilter.value}} = 'popular' AND p.downloads > 1000000)
OR ({{components.statusFilter.value}} = 'growing' AND p.downloads BETWEEN 100000 AND 1000000)
OR ({{components.statusFilter.value}} = 'new' AND p.downloads < 100000)
)
ORDER BY p.downloads DESC
LIMIT 50
JavaScript queries
// ToolJet RunJS query: "processPackages"
const packages = queries.listPackages.data
const processed = packages.map(pkg => ({
...pkg,
downloads_formatted: new Intl.NumberFormat().format(pkg.downloads),
trend: pkg.downloads > 1000000 ? "🔥" : pkg.downloads > 100000 ? "📈" : "🆕",
days_old: Math.floor(
(Date.now() - new Date(pkg.created_at).getTime()) / (1000 * 60 * 60 * 24)
),
}))
const stats = {
total: packages.length,
total_downloads: packages.reduce((sum, p) => sum + p.downloads, 0),
popular: packages.filter(p => p.downloads > 1000000).length,
average_downloads: Math.round(
packages.reduce((sum, p) => sum + p.downloads, 0) / packages.length
),
}
return { packages: processed, stats }
REST API queries
// ToolJet REST API query: "fetchNpmDetails"
// URL: https://registry.npmjs.org/{{components.table1.selectedRow.name}}
// Method: GET
// Transform response:
const data = self.data
return {
name: data.name,
description: data.description,
latest: data["dist-tags"]?.latest,
license: data.license,
homepage: data.homepage,
repository: data.repository?.url?.replace("git+", "").replace(".git", ""),
maintainers: data.maintainers?.map(m => m.name).join(", "),
keywords: data.keywords?.join(", ") || "None",
created: new Date(data.time?.created).toLocaleDateString(),
modified: new Date(data.time?.modified).toLocaleDateString(),
versions_count: Object.keys(data.versions || {}).length,
}
Workflows (ToolJet Workflows)
// ToolJet Workflow: "syncPackageData"
// Trigger: Scheduled (daily at 6:00 AM)
// Node 1: Fetch packages from database
const packages = await tooljet.db.query(
"SELECT id, name, downloads FROM packages WHERE active = true"
)
// Node 2: Fetch npm data for each
const updates = []
for (const pkg of packages) {
try {
const response = await fetch(
`https://api.npmjs.org/downloads/point/last-week/${pkg.name}`
)
const data = await response.json()
if (data.downloads && data.downloads !== pkg.downloads) {
updates.push({
id: pkg.id,
name: pkg.name,
old: pkg.downloads,
new: data.downloads,
})
}
} catch (e) {
console.warn(`Failed to fetch ${pkg.name}:`, e.message)
}
}
// Node 3: Update database
for (const update of updates) {
await tooljet.db.query(
"UPDATE packages SET downloads = $1, updated_at = NOW() WHERE id = $2",
[update.new, update.id]
)
}
// Node 4: Send notification
if (updates.length > 0) {
await tooljet.slack.send({
channel: "#package-updates",
text: `Updated ${updates.length} packages:\n${
updates.map(u => `• ${u.name}: ${u.old.toLocaleString()} → ${u.new.toLocaleString()}`).join("\n")
}`,
})
}
return { updated: updates.length }
Multi-environment
// ToolJet supports multiple environments:
// Development → Staging → Production
// Environment variables are scoped:
// DEV: DATABASE_URL = postgresql://localhost:5432/pkgpulse_dev
// STAG: DATABASE_URL = postgresql://staging-db:5432/pkgpulse_stag
// PROD: DATABASE_URL = postgresql://prod-db:5432/pkgpulse
// Queries use the same code but connect to env-specific data sources
// Promote apps through environments with approval workflows
Feature Comparison
| Feature | Retool | Appsmith | ToolJet |
|---|---|---|---|
| License | Proprietary | Apache 2.0 | AGPL v3 |
| Self-hosted | ✅ (paid) | ✅ (free) | ✅ (free) |
| Cloud hosted | ✅ | ✅ | ✅ |
| Drag-and-drop | ✅ | ✅ | ✅ |
| Components | 100+ | 45+ | 45+ |
| SQL queries | ✅ | ✅ | ✅ |
| REST/GraphQL | ✅ | ✅ | ✅ |
| JavaScript | ✅ | ✅ (JS Objects) | ✅ (RunJS) |
| Workflows | ✅ (Retool Workflows) | ❌ | ✅ |
| Git sync | ❌ (source control) | ✅ (native Git) | ✅ |
| Multi-environment | ✅ | ✅ | ✅ |
| Mobile apps | ✅ (Retool Mobile) | ❌ | ❌ |
| Custom components | ✅ (React) | ✅ (iframe) | ✅ (React) |
| AI features | ✅ (Retool AI) | ❌ | ✅ (Copilot) |
| RBAC | ✅ | ✅ | ✅ |
| Audit logs | ✅ | ✅ | ✅ |
| Integrations | 100+ | 45+ | 50+ |
| Pricing | Per user/month | Free (self-host) | Free (self-host) |
When to Use Each
Use Retool if:
- Want the most polished, feature-rich internal tool builder
- Need mobile apps, workflows, and AI features in one platform
- Have budget for per-user pricing and prefer managed infrastructure
- Building complex internal tools with 100+ integrations
Use Appsmith if:
- Want a fully open-source (Apache 2.0) internal tool builder
- Need Git-based version control for your internal apps
- Prefer self-hosted with no licensing restrictions
- Building developer-friendly internal tools with JS Objects
Use ToolJet if:
- Want open-source with built-in workflow automation
- Need multi-environment support (dev/staging/prod)
- Prefer self-hosted with visual workflow builder
- Building internal tools with automated data pipelines
Methodology
GitHub stars and features as of March 2026. Feature comparison based on Retool (cloud), Appsmith v1.x (self-hosted), and ToolJet v2.x (self-hosted).
Compare developer tools and internal platforms on PkgPulse →