Skip to main content

Full-code apps quickstart

This guide walks you through building your first full-code app. We'll create a React app from the Windmill UI, explore the scaffolded code, add a second backend runnable, and wire both into a polished frontend.

Full code apps

Full-code apps give you complete control over your UI with React or Svelte, while Windmill handles backend execution, permissions and deployment.

Full-code appsLow-code apps
UICustom React/Svelte componentsDrag-and-drop component library
Frontend logicFull framework features (hooks, stores, routing)Connecting components + inline scripts
BackendScripts in backend/ folder, any languageRunnables panel, inline or workspace scripts
Local devwmill app dev with hot reloadWeb-based editor only
Best forCustom UIs, complex interactions, existing codebasesQuick dashboards, forms, CRUD interfaces

Step 1: Create the app

From the platform

From your Windmill workspace, click + App and select Full-code App.

Pick full-code app

In the setup dialog:

  1. Pick a framework (React or Svelte 5), for the example we'll use React 19
  2. Choose a Data configuration. Here we'll use a new datatable. It's not required for apps to have a datatable, it will be used only in a dedicated section of the quickstart.
  3. Start the app 'without AI'. Or just enter a prompt and start with AI, and you're done for the quickstart :)

Pick React framework

The UI editor opens with a scaffolded project.

From the CLI

You can do the same from the Windmill CLI:

wmill app new

The wizard prompts for the same choices. Then install dependencies and start the dev server:

cd f/folder/my_app.raw_app
npm install
wmill app dev

This starts a local server with hot reload at http://localhost:4000.

Step 2: Explore the scaffolded project

The created app has this structure:

f/folder/my_app.raw_app/
├── raw_app.yaml # App metadata and configuration
├── package.json # Frontend dependencies
├── index.tsx # Entry point (renders App)
├── App.tsx # Main React component
├── index.css # Styles
└── backend/
├── a.yaml # Sample backend runnable config
└── a.ts # Sample backend runnable code

Default App.tsx

The default App.tsx

The scaffolded App.tsx looks like this:

import React, { useState } from 'react'
import { backend } from './wmill'
import './index.css'

const App = () => {
const [value, setValue] = useState(undefined as string | undefined)
const [loading, setLoading] = useState(false)

async function runA() {
setLoading(true)
try {
setValue(await backend.a({ x: 42 }))
} catch (e) {
console.error(e)
}
setLoading(false)
}

return <div style={{ width: "100%" }}>
<h1>hello world</h1>

<button style={{ marginTop: "2px" }} onClick={runA}>Run 'a'</button>

<div style={{ marginTop: "20px", width: '250px' }} className='myclass'>
{loading ? 'Loading ...' : value ?? 'Click button to see value here'}
</div>
</div>
}

export default App

It imports backend from ./wmill - this is an auto-generated module that provides typed functions to call your backend runnables. Here, clicking the button calls backend.a() which runs the sample runnable a in the backend/ folder.

The default backend runnable

The sample runnable backend/a.ts is a simple TypeScript function:

// import * as wmill from "windmill-client"

export async function main(x: string) {
return x
}

You can preview your UI by selecting 'App.tsx' to see it in the right pane, or by clicking 'Preview' in the UI editor for a fullscreen view. If you're using the CLI, open http://localhost:4000 in your browser to access the app. When you click the button, it sends a request to the backend and displays the returned result.

Default backend runnable

How it works

The key concept: backend.a({ x: 42 }) sends the call to a Windmill worker that executes backend/a.ts and returns the result. Your frontend never runs the backend code directly - it goes through Windmill's execution engine via WebSocket, which means you get logging, permissions and error handling for free.

Step 3: Edit and add backend runnables

The scaffolded app comes with one runnable (a). Let's update it and add a second one.

From the UI editor

Click on the a runnable in the runnables panel. Give it the summary "Multiply" and replace the code with:

// backend/a.ts
export async function main(x: number) {
// Simulate some processing
await new Promise(resolve => setTimeout(resolve, 500));
return `Result: ${x} × 2 = ${x * 2}`;
}

Multiply runnable

Now add a second runnable - this time in Python as you can mix languages within the same app:

  1. In the runnables panel on the right, click the + button
  2. Select Python as the language
  3. Name it b and give it the summary "Get timestamp"

Choose language

Paste this code:

# backend/b.py
from datetime import datetime

def main(format: str):
now = datetime.now()
if format == "iso":
return now.isoformat()
elif format == "locale":
return now.strftime("%c")
else:
return str(now)

Get timestamp runnable

As you can see, the auto-generated UI updated with the new input name (format).

You now have two runnables in different languages: a (TypeScript) doubles a number, b (Python) returns a formatted date. The frontend calls them the exact same way - it doesn't need to know which language runs behind the scenes.

From local files

Alternatively, work directly in backend/:

  • Edit backend/a.ts with the multiply code above
  • Create backend/b.py with the Python code above

The language is auto-detected from the file extension (.ts for TypeScript, .py for Python) and the runnable ID is derived from the filename (a, b).

Step 4: Build the frontend

Now let's update App.tsx to call both runnables. The auto-generated wmill module automatically picks up the new b runnable, so we can call backend.a() and backend.b() right away.

Replace the content of App.tsx with:

import React, { useState } from 'react'
import { backend } from './wmill'
import './index.css'

const App = () => {
const [valueA, setValueA] = useState<string | undefined>(undefined)
const [valueB, setValueB] = useState<string | undefined>(undefined)
const [loadingA, setLoadingA] = useState(false)
const [loadingB, setLoadingB] = useState(false)
const [inputNumber, setInputNumber] = useState(42)

async function runA() {
setLoadingA(true)
try {
setValueA(await backend.a({ x: inputNumber }))
} catch (e) {
console.error('Error running a:', e)
}
setLoadingA(false)
}

async function runB() {
setLoadingB(true)
try {
setValueB(await backend.b({ format: 'locale' }))
} catch (e) {
console.error('Error running b:', e)
}
setLoadingB(false)
}

async function runBoth() {
setLoadingA(true)
setLoadingB(true)
try {
const [resultA, resultB] = await Promise.all([
backend.a({ x: inputNumber }),
backend.b({ format: 'iso' })
])
setValueA(resultA)
setValueB(resultB)
} catch (e) {
console.error('Error running both:', e)
}
setLoadingA(false)
setLoadingB(false)
}

return (
<div className="container">
<h1>Full-code app demo</h1>
<p className="subtitle">Calling 2 backend runnables</p>

<div className="input-section">
<label>
Input number:
<input
type="number"
value={inputNumber}
onChange={(e) => setInputNumber(Number(e.target.value))}
/>
</label>
</div>

<div className="buttons">
<button onClick={runA} disabled={loadingA}>
{loadingA ? 'Running...' : 'Multiply number'}
</button>
<button onClick={runB} disabled={loadingB}>
{loadingB ? 'Running...' : 'Get timestamp'}
</button>
<button onClick={runBoth} disabled={loadingA || loadingB} className="primary">
Run both
</button>
</div>

<div className="results">
<div className="result-card">
<h3>Multiply (TypeScript)</h3>
<div className="result-value">
{loadingA ? 'Loading...' : valueA ?? 'Click a button to see result'}
</div>
</div>

<div className="result-card">
<h3>Timestamp (Python)</h3>
<div className="result-value">
{loadingB ? 'Loading...' : valueB ?? 'Click a button to see result'}
</div>
</div>
</div>
</div>
)
}

export default App

A few things to notice:

  • Each button calls a different backend runnable (backend.a() or backend.b())
  • Run both uses Promise.all to call both runnables in parallel - each one runs as a separate Windmill job
  • The format parameter on backend.b() is passed as an argument, just like x on backend.a()
  • a runs TypeScript on a Bun worker, b runs Python - the frontend doesn't need to care

Update the styles

Replace index.css to give the app a cleaner look:

.container {
max-width: 600px;
margin: 0 auto;
padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}

h1 {
margin-bottom: 5px;
color: #333;
}

.subtitle {
color: #666;
margin-top: 0;
margin-bottom: 24px;
}

.input-section {
margin-bottom: 20px;
}

.input-section label {
display: flex;
align-items: center;
gap: 10px;
font-weight: 500;
}

.input-section input {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 16px;
width: 100px;
}

.buttons {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 24px;
}

button {
padding: 10px 18px;
border: 1px solid #ddd;
border-radius: 6px;
background: white;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}

button:hover:not(:disabled) {
background: #f5f5f5;
border-color: #ccc;
}

button:disabled {
opacity: 0.6;
cursor: not-allowed;
}

button.primary {
background: #3b82f6;
color: white;
border-color: #3b82f6;
}

button.primary:hover:not(:disabled) {
background: #2563eb;
}

.results {
display: grid;
gap: 16px;
}

.result-card {
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 16px;
background: #fafafa;
}

.result-card h3 {
margin: 0 0 10px 0;
font-size: 14px;
color: #666;
text-transform: uppercase;
letter-spacing: 0.5px;
}

.result-value {
font-size: 16px;
color: #333;
font-family: 'Monaco', 'Menlo', monospace;
word-break: break-all;
}

See the preview in the right part of the screen of App.tsx (or click 'Preview' in the UI editor to see it in full screen, or check http://localhost:4000 if using the CLI) to see the result. Try clicking each button individually, then "Run both" to see parallel execution.

Updated App.tsx

Index CSS

Svelte

If you chose Svelte 5 instead of React, the same pattern applies. Import backend from ./wmill and use Svelte's $state and $effect for reactivity. See the frontend reference for Svelte examples.

How backend calls work

Every call to backend.a() or backend.b() is a real Windmill job execution:

  • backend.xxx(args) - calls a runnable and waits for the result (synchronous)
  • backendAsync.xxx(args) - starts a runnable and returns a job ID immediately (for long-running tasks)
  • waitJob(jobId) - waits for an async job to complete

Step 5: Use a data table for persistence

So far our runnables compute values on the fly. Full-code apps can also read and write to Windmill data tables - a built-in storage layer.

Set up a database

First, make sure a database is configured in your workspace. Go to Workspace settings > Data Tables and set up a database connection (or use the Custom instance database if available).

Create table

Add a table to your app

In the raw app editor, open the Data section on the left panel and click the + button. You can either pick an existing table (public or from other apps) or create a new one for your app.

Let's create a new table:

  1. Click + in the Data section
  2. Select Create new table
  3. Name it computation_logs
  4. Define the columns:
    • idBIGSERIAL (primary key, added by default)
    • inputINT
    • resultTEXT
    • created_atTIMESTAMP, default now()
  5. Click Create table

Create table columns

The table is now whitelisted for your app. You can view its schema and data from the Data section.

Add a SQL runnable

Now create a backend runnable that queries this table. In the runnables panel, click + and select PostgreSQL as the language. Name it get_logs.

For the database resource, pick the same resource as the one configured in your workspace Data Tables settings.

-- backend/get_logs.pg.sql
SELECT * FROM app_demo.computation_logs ORDER BY created_at DESC LIMIT 10;

Get logs runnable

From the frontend, call it like any other runnable:

const logs = await backend.get_logs();

The frontend code doesn't need to know whether a runnable is TypeScript, Python or SQL - the wmill module handles them all the same way.

CLI

From local files, the .pg.sql extension tells Windmill to run the script as a PostgreSQL query. Other SQL dialects are supported too (.my.sql for MySQL, .bq.sql for BigQuery, etc.). See the backend runnables reference for the full list.

Step 6: Deploy

From the UI editor

Click the Deploy button in the toolbar. Each deployment creates a new version of your app.

Deploy

Deployed app

From the CLI

Generate lock files for your runnables and push:

wmill app generate-locks
wmill sync push

Make it public

To make the app accessible without login, add public: true to raw_app.yaml:

summary: "Full-code app demo"
public: true

Admins can also set a custom URL path:

custom_path: "my-demo"

The app is then accessible at https://<instance>/apps/custom/my-demo.

Runnable configuration

So far we've used code-only runnables (just a file in backend/). For more control, you can add a .yaml config file alongside the code to pre-fill inputs:

# backend/a.yaml
type: inline
fields:
x:
type: static
value: 100

This pre-fills the x parameter so the frontend doesn't need to pass it. You can also reference existing workspace scripts or flows instead of writing inline code:

# backend/send_notification.yaml
type: script
path: f/production/send_slack_notification

Next steps

You now have a working full-code app with a custom React frontend calling multiple backend runnables. From here you can: