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
Self-Hosting and Security Architecture
For teams with strict data residency or compliance requirements, the self-hosting story differs meaningfully across these platforms. Appsmith's Apache 2.0 license makes it the most permissive choice — you can deploy it internally without any licensing concerns even for commercial use. ToolJet uses AGPL v3, which technically requires that modifications to the core server code be open-sourced if distributed, though most enterprise deployments running the server internally are not affected. Retool's self-hosted tier is commercially licensed and requires a paid plan, but it includes enterprise features like single sign-on, audit logs, and RBAC that the open-source alternatives provide only partially. When self-hosting, all three platforms can be deployed behind a VPN with no public internet access, which is critical for internal tools that touch production databases containing customer data.
TypeScript and Code Quality in Internal Tools
One underappreciated aspect of these platforms is how they handle the developer code inside them. Retool's JavaScript transformers and queries run in a sandboxed browser context without TypeScript support, so you're writing raw JavaScript with no type checking. Appsmith's JS Objects also run as vanilla JavaScript in the browser. ToolJet's RunJS environment similarly lacks TypeScript support natively. This means complex business logic embedded in low-code platforms can become difficult to maintain as it grows — a common pattern is to implement complex transformations as API endpoints in your backend (which can be TypeScript) and call them from the internal tool rather than encoding logic directly in query transformers. This hybrid approach keeps business logic testable and maintainable while still leveraging the visual builder for UI composition.
Role-Based Access Control and Audit Logging
Internal tools often need fine-grained access control because they expose powerful operations — bulk data edits, user account management, financial data access — that should be restricted based on team role. Retool's RBAC system in the business and enterprise tiers allows creating custom permission groups with specific permissions per application and per data source. An "analyst" group might have read-only access to the database queries and view-only access to the application UI, while an "admin" group can execute write operations and manage other users. Appsmith's RBAC system provides workspace-level and application-level permission management, with roles (Administrator, Developer, Viewer) controlling what actions users can take. Audit logs in Retool's enterprise tier record every user action — which query ran, what parameters were used, who triggered it, and when — providing a complete audit trail for compliance requirements. ToolJet similarly offers audit logging as part of its enterprise offering, capturing all user interactions with the platform for security and compliance review.
Production Reliability and Incident Response
Internal tools often have different reliability expectations than customer-facing products, but when an internal tool is the only interface for managing production data, downtime becomes critical. Retool Cloud has published SLAs and historical uptime data, while self-hosted deployments are entirely the operator's responsibility. For self-hosted Appsmith and ToolJet, running behind a load balancer with multiple replicas and health checks is important for high-availability setups. Both support PostgreSQL as their metadata database, so existing database HA patterns (Patroni, managed RDS Multi-AZ) apply directly. One operational consideration specific to these tools: when the internal tool is down, operators need manual runbooks or direct database access to perform critical operations — documenting these alternatives before an incident is a production readiness requirement that teams frequently overlook.
Performance and Scaling Considerations
For internal tools serving dozens of concurrent users, performance optimization is less about raw query speed and more about data freshness and user experience. All three platforms support manual query re-run triggers and automatic query execution on component state changes, so designing when queries execute is important for avoiding unnecessary database load. Retool's transformer caching prevents the same computed result from being recalculated on every render cycle. Appsmith's dependency graph determines query execution order automatically, which helps but can also lead to unexpected cascading query calls when component state changes propagate. ToolJet's multi-environment support means you can performance-test against staging data before promoting changes to the production environment, which is a meaningful workflow advantage for data-heavy internal tools.
Ecosystem Context and Long-Term Maintainability
The internal tools market has consolidated significantly since 2020, with Retool emerging as the dominant commercial player and Appsmith and ToolJet capturing the open-source segment. Retool's 2023 security incident (which exposed customer data through a compromised SSO provider) accelerated enterprise interest in self-hosted alternatives, and both Appsmith and ToolJet saw increased adoption as a result. For long-term maintainability, consider that Retool's commercial model funds a large engineering team focused exclusively on the product, while Appsmith and ToolJet depend on a mix of community contributions and commercial add-ons. The practical implication is that Retool typically ships new integrations and framework support faster, but breaking changes in major versions can be difficult to manage when you're not in control of the upgrade schedule in the cloud version.
Database and Data Source Connectivity
The range and depth of data source connectors directly affects how quickly you can build internal tools against your existing infrastructure. Retool's 100+ native connectors include direct database drivers for PostgreSQL, MySQL, MongoDB, Redis, BigQuery, and Snowflake, plus REST and GraphQL API connections with configurable authentication (OAuth, API key, bearer token, custom headers). The database connections support SSH tunneling, which is essential for connecting to databases in private VPCs without exposing them to the public internet. Appsmith and ToolJet both support the same core database types through their query editors, with Appsmith supporting approximately 40 data sources and ToolJet approximately 50, including several API integrations (OpenAI, Stripe, Twilio) that reduce the need for custom REST query configuration. For teams with complex data sources — multiple production databases, data warehouses, internal APIs with custom auth — Retool's connector depth reduces integration friction at the cost of higher licensing. For simpler stacks (a single PostgreSQL database and a few REST APIs), Appsmith or ToolJet's connector coverage is entirely sufficient.
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 →
See also: AVA vs Jest and Mermaid vs D3.js vs Chart.js 2026, acorn vs @babel/parser vs espree.