Skip to content
Home Guides Development
Development Expert

Build a Custom Dashboard with Retool (Better Than Tableau)

Stop paying $70/user/month for Tableau. Build production-ready internal dashboards in 4 hours with Retool that connect to any database-no vendor lock-in.

FW
FW Delta
12 min 4-6 hours
The Problem

Tableau charges $840/user/year and locks your data in proprietary formats. Custom dashboards with React take 2+ weeks to build.

The Fix

Retool lets you build fully custom internal tools in hours with drag-and-drop components that connect directly to your database-no ETL needed.

Why Tableau Is Bankrupting Your Ops Team

Your operations team needs real-time dashboards to manage orders, inventory, and customer support. Tableau promises “business intelligence” but charges $70/user/month ($840/year) and requires a data analyst to build every view. Worse, it locks your data in proprietary extracts that break when your schema changes.

We’ve migrated 30+ companies from Tableau to custom dashboards through our Dashboards service. The typical result? $10,000+/year saved on licensing and 10x faster iteration because your ops team can edit views themselves.

Unlike rigid Tableau dashboards that require data warehouse ETL pipelines, Retool connects directly to your production database with read-only queries-no data copying needed.

The Modern Dashboard Stack: Retool + PostgreSQL + REST APIs

Here’s the production-ready architecture we use in our custom development implementations:

Why Retool?

  • Drag-and-drop UI builder: No React/Vue coding required
  • Native database connectors: PostgreSQL, MySQL, MongoDB, Snowflake, etc.
  • REST API integration: Connect to Stripe, Shopify, HubSpot directly
  • Access control: Role-based permissions out of the box
  • Self-hosted option: Run on your infrastructure (no vendor lock-in)

Cost comparison:

  • Tableau: $70/user/month = $840/year × 10 users = $8,400/year
  • Retool: $10/user/month = $120/year × 10 users = $1,200/year
  • Savings: $7,200/year (and you can self-host for free after year 1)

Step 1: Set Up Retool (Cloud or Self-Hosted)

Option A: Retool Cloud (fastest)

  1. Go to retool.com and sign up
  2. Choose “Start with free plan” (5 users, unlimited apps)
  3. Skip this section

Option B: Self-Hosted (recommended for production)

# Run Retool on your server (Docker required)
git clone https://github.com/tryretool/retool-onpremise.git
cd retool-onpremise

# Generate license key (contact Retool for free self-hosted license)
# Add to docker.env:
# LICENSE_KEY=your-license-key

# Start Retool
docker-compose up -d

# Access at http://localhost:3000

Pro tip: Self-hosting means no per-user fees after the first year. You only pay for infrastructure ($20-50/month on AWS/GCP).

Step 2: Connect Your Data Sources

Retool’s killer feature: connect to any data source without ETL.

Connect PostgreSQL:

  1. In Retool, go to Resources → Create New
  2. Select PostgreSQL
  3. Enter connection details:
    Host: your-db.aws.rds.amazonaws.com
    Port: 5432
    Database: production
    Username: retool_readonly
    Password: [secure password]
  4. Click Test connectionSave

CRITICAL: Use read-only credentials

-- Create read-only user in your PostgreSQL database
CREATE USER retool_readonly WITH PASSWORD 'secure_password';
GRANT CONNECT ON DATABASE production TO retool_readonly;
GRANT USAGE ON SCHEMA public TO retool_readonly;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO retool_readonly;

-- Auto-grant SELECT on future tables
ALTER DEFAULT PRIVILEGES IN SCHEMA public
  GRANT SELECT ON TABLES TO retool_readonly;

Connect REST APIs (Stripe, Shopify, etc.):

  1. Go to Resources → Create New → REST API
  2. Enter base URL: https://api.stripe.com/v1
  3. Add authentication:
    • Type: Bearer Token
    • Token: sk_live_your_stripe_key
  4. Save as “Stripe API”

Need help connecting custom integrations? We can set up secure API gateways with rate limiting.

Step 3: Build Your First Dashboard (Order Management)

Let’s build a real-time order dashboard. This is the #1 use case we see at our dashboard implementation service.

Dashboard features:

  • Real-time order list with filters (status, date range, customer)
  • Order details panel (line items, shipping, customer info)
  • Quick actions (mark shipped, refund, email customer)
  • Revenue metrics (daily, weekly, monthly trends)

Create a New App

  1. In Retool, click Create → App
  2. Name it “Order Dashboard”
  3. Choose “Desktop” layout

Add the Order List (Table Component)

  1. Drag Table component onto canvas
  2. Rename to orderTable
  3. Click New Query → Select “PostgreSQL” resource
  4. Write query:
-- query: getOrders
SELECT 
  o.id,
  o.order_number,
  o.created_at,
  o.status,
  o.total_price,
  o.currency,
  c.email AS customer_email,
  c.first_name || ' ' || c.last_name AS customer_name,
  COUNT(oi.id) AS item_count
FROM orders o
LEFT JOIN customers c ON o.customer_id = c.id
LEFT JOIN order_items oi ON o.id = oi.order_id
WHERE 
  o.created_at >= {{ dateRangePicker.value.start }}
  AND o.created_at <= {{ dateRangePicker.value.end }}
  AND ({{ statusFilter.value }} = 'all' OR o.status = {{ statusFilter.value }})
GROUP BY o.id, c.id
ORDER BY o.created_at DESC
LIMIT 100;
  1. Set Data source for table: {{ getOrders.data }}
  2. Configure columns:
    • Hide id column
    • Format created_at as date: {{ moment(currentRow.created_at).format('MMM D, YYYY h:mm A') }}
    • Format total_price as currency: {{ '$' + currentRow.total_price.toFixed(2) }}
    • Add badge for status: Use conditional color (green = fulfilled, yellow = pending, red = cancelled)

Add Filters

Date Range Picker:

  1. Drag Date Range Picker above table
  2. Rename to dateRangePicker
  3. Set default: Last 7 days
  4. Enable “Run queries automatically on change”

Status Filter:

  1. Drag Select dropdown
  2. Rename to statusFilter
  3. Set options:
    [
      { label: 'All Orders', value: 'all' },
      { label: 'Pending', value: 'pending' },
      { label: 'Fulfilled', value: 'fulfilled' },
      { label: 'Cancelled', value: 'cancelled' }
    ]
  4. Default value: 'all'

Add Order Details Panel

  1. Drag Container to right side of screen
  2. Rename to orderDetailsPanel
  3. Set visibility: {{ orderTable.selectedRow !== null }}
  4. Inside container, add Text components:
Order #{{ orderTable.selectedRow.order_number }}
Customer: {{ orderTable.selectedRow.customer_name }}
Email: {{ orderTable.selectedRow.customer_email }}
Status: {{ orderTable.selectedRow.status }}
Total: ${{ orderTable.selectedRow.total_price }}
  1. Add Table for line items:
    • Create query getOrderItems:
SELECT 
  product_name,
  quantity,
  price,
  quantity * price AS subtotal
FROM order_items
WHERE order_id = {{ orderTable.selectedRow.id }};
  • Set table data: {{ getOrderItems.data }}

Add Quick Actions

Mark as Shipped button:

  1. Drag Button into details panel
  2. Label: “Mark as Shipped”
  3. Click → New Query → Type: PostgreSQL
  4. Query:
-- query: markShipped
UPDATE orders
SET 
  status = 'fulfilled',
  fulfilled_at = NOW()
WHERE id = {{ orderTable.selectedRow.id }}
RETURNING *;
  1. After success → Trigger getOrders to refresh table
  2. Show success notification: "Order #{{ orderTable.selectedRow.order_number }} marked as shipped"

Send Email button:

  1. Add another Button
  2. Label: “Email Customer”
  3. Click → New Query → Type: REST API
  4. Create SendGrid resource first (or use SMTP):
// query: sendEmail
// Method: POST
// URL: https://api.sendgrid.com/v3/mail/send
// Headers: { "Authorization": "Bearer {{ sendgridKey }}" }
// Body:
{
  "personalizations": [{
    "to": [{"email": "{{ orderTable.selectedRow.customer_email }}"}],
    "subject": "Your order #{{ orderTable.selectedRow.order_number }} has been updated"
  }],
  "from": {"email": "support@yourstore.com"},
  "content": [{
    "type": "text/html",
    "value": "<p>Hi {{ orderTable.selectedRow.customer_name }},</p><p>Your order status: {{ orderTable.selectedRow.status }}</p>"
  }]
}

Want automated email workflows instead of manual clicks? Check our workflow automation service.

Step 4: Add Revenue Metrics (Charts)

Ops teams need to see trends, not just raw data.

Daily Revenue Chart:

  1. Drag Chart component above table
  2. Create query:
-- query: getDailyRevenue
SELECT 
  DATE(created_at) AS date,
  SUM(total_price) AS revenue,
  COUNT(*) AS order_count
FROM orders
WHERE 
  created_at >= {{ dateRangePicker.value.start }}
  AND created_at <= {{ dateRangePicker.value.end }}
  AND status != 'cancelled'
GROUP BY DATE(created_at)
ORDER BY date ASC;
  1. Configure chart:
    • Type: Line Chart
    • X-axis: {{ getDailyRevenue.data.map(row => row.date) }}
    • Y-axis: {{ getDailyRevenue.data.map(row => row.revenue) }}
    • Label: “Daily Revenue”

Metric Cards (KPIs):

  1. Drag 3 Statistic components side-by-side
  2. Configure:
    • Total Orders: {{ getOrders.data.length }}
    • Total Revenue: ${{ getOrders.data.reduce((sum, row) => sum + row.total_price, 0).toFixed(2) }}
    • Avg Order Value: ${{ (getOrders.data.reduce((sum, row) => sum + row.total_price, 0) / getOrders.data.length).toFixed(2) }}

Step 5: Add Access Control (Role-Based Permissions)

Different teams need different access levels.

Create user groups:

  1. Go to Settings → Groups
  2. Create groups:
    • ops_team (view + edit orders)
    • support_team (view only)
    • admin (full access)

Set app permissions:

  1. In your app, go to Settings → Permissions
  2. Set:
    • ops_team: Can use app
    • support_team: Can use app (but disable “Mark Shipped” button for this group)
    • admin: Full access

Hide sensitive data:

// In table columns, conditionally hide customer emails:
{{ currentUser.groups.includes('admin') ? currentRow.customer_email : '***@***' }}

Need enterprise-grade security controls? We implement SSO, audit logs, and field-level encryption.

Step 6: Connect Live APIs (Stripe, Shopify, etc.)

Most dashboards need data from multiple sources.

Add Stripe payment data:

  1. Create query using Stripe resource:
// query: getStripePayments
// Method: GET
// URL: /charges?limit=100&created[gte]={{ Math.floor(dateRangePicker.value.start / 1000) }}

// Display in separate table

Join with order data:

// In a JavaScript query:
const orders = {{ getOrders.data }};
const payments = {{ getStripePayments.data.data }};

return orders.map(order => ({
  ...order,
  stripe_charge_id: payments.find(p => p.metadata.order_id === order.id)?.id,
  payment_method: payments.find(p => p.metadata.order_id === order.id)?.payment_method_details?.type
}));

Production Deployment Checklist

Before sharing with your team:

  1. Test with read-only DB user: Never use admin credentials
  2. Add error handling: Wrap all queries in try-catch with user-friendly error messages
  3. Set query timeouts: Prevent long-running queries (30-second max)
  4. Add loading states: Show spinners while data loads
  5. Optimize queries: Add indexes on filtered columns (created_at, status)
  6. Set up monitoring: Track query performance and error rates
  7. Document for ops team: Create a quick “How to Use” doc
  8. Deploy to custom domain: Use dashboard.yourcompany.com instead of yourcompany.retool.com

Need help with production monitoring? We set up alerts for slow queries and downtime.

Advanced: Multi-Tab Dashboards

Real internal tools need multiple views:

Add tabs:

  1. Drag Tabs component
  2. Create tabs:
    • Orders (your current view)
    • Inventory (low stock alerts)
    • Customers (support ticket history)
    • Analytics (cohort analysis, retention)

Inventory tab example:

-- query: getLowStockItems
SELECT 
  p.id,
  p.name,
  p.sku,
  i.quantity_available,
  i.quantity_reserved,
  i.reorder_threshold
FROM products p
JOIN inventory i ON p.id = i.product_id
WHERE i.quantity_available < i.reorder_threshold
ORDER BY (i.quantity_available - i.reorder_threshold) ASC;

Display in table with color-coded stock levels (red = out of stock, yellow = low, green = good).

Common Pitfalls (And How We Fix Them)

Pitfall 1: Using production DB credentials Always create a read-only user. Write operations should go through an API layer with validation.

Pitfall 2: N+1 query problems Don’t query inside table row renders. Fetch all data upfront with JOINs.

Pitfall 3: No query result caching Enable Retool’s query caching (5-minute default) for expensive queries that don’t need real-time data.

Pitfall 4: Hardcoding values Use variables for everything: API keys in Resources, filters in components, not in raw queries.

Pitfall 5: Ignoring mobile users Retool dashboards are desktop-first. For mobile access, build a separate mobile app or use Retool Mobile.

Retool vs. Tableau: The Real Comparison

FeatureTableauRetool
Cost/user/year$840$120 (or $0 self-hosted)
Data connectionRequires ETL/extractsDirect DB queries
Build time2-3 days with analyst4-6 hours self-service
CustomizationLimited to charts/tablesFull CRUD operations
Real-time dataRequires extract refreshAlways live
Write operationsNot supportedNative (with permission controls)
API integrationsLimited connectorsAny REST/GraphQL API
Self-hostingEnterprise only ($4,000+)Included in free tier

For more details, check our Tableau comparison page.

Need Professional Help?

This dashboard architecture is tested across operations, support, sales, and finance teams. Our Custom Dashboard service includes:

  • Requirements gathering: Interview your team to understand exact workflow needs
  • Multi-source integration: Connect DBs + APIs + spreadsheets in one dashboard
  • Custom business logic: Complex calculations, forecasting, anomaly detection
  • Access control setup: SSO, role-based permissions, audit logging
  • Performance optimization: Query tuning, caching strategies, lazy loading
  • Team training: Teach your ops team to edit dashboards themselves

Most clients launch their first dashboard in 2 weeks and save $10,000+/year on BI tool licensing.

Book a free 30-minute dashboard planning session: Schedule here

Next Guide

Want to connect Retool to Snowflake or BigQuery? Check out our guide on Building Data Warehouse Dashboards with Retool + dbt.