Skip to main content

Retool vs Appsmith vs ToolJet: Internal Tool Builders (2026)

·PkgPulse Team

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

FeatureRetoolAppsmithToolJet
LicenseProprietaryApache 2.0AGPL v3
Self-hosted✅ (paid)✅ (free)✅ (free)
Cloud hosted
Drag-and-drop
Components100+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
Integrations100+45+50+
PricingPer user/monthFree (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 →

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.