Your First Custom App: Coffee Tracker v1
Overview
In this guide, we'll build Coffee Tracker v1 — our first custom_app for registering roasted coffee beans.
You'll learn:
- How forms (schema.json + ui.json) work together
- How to structure a custom_app bundle
- How to upload and test your app in Formulus
Time to complete: 30–45 minutes
Prerequisites:
- Comfortable editing JSON and HTML
- Access to a Synkronus server and Formulus app
- (Recommended) Synkronus Quickstart for a test server
What is a custom_app?
A custom_app is a zipped archive with two folders:
coffee_tracker-v1.0.0.zip
├── app/ # HTML + JavaScript for the UI
│ ├── index.html
│ └── style.css
└── forms/ # Questionnaire specifications
└── register_coffee/
├── schema.json # Data shape
└── ui.json # Form layout
Part 1: Create the forms
The form specification
A form is defined by two files:
- schema.json — What data is collected (data shape, types, required fields)
- ui.json — How the form looks and flows (screens, layout, field order)
Coffee registration form
We'll collect:
- Photo of the beans
- Bean variety name
- Country of origin
- Roaster name
- Roasting date
- Roast level
Folder structure:
forms/
└── register_coffee/
├── schema.json
└── ui.json
schema.json
Create forms/register_coffee/schema.json:
{
"type": "object",
"properties": {
"photo": {
"type": "object",
"format": "photo",
"title": "Bean photo",
"description": "Take a picture of the beans in portrait mode"
},
"name": {
"type": "string",
"title": "Bean variety",
"description": "Name of the coffee variety"
},
"origin": {
"type": "string",
"title": "Country of Origin"
},
"roaster": {
"type": "string",
"title": "Roaster",
"description": "Who roasted the coffee"
},
"roast_date": {
"type": "string",
"format": "date-time",
"title": "Roasting date",
"description": "Date and time when this batch was roasted"
},
"roast_profile": {
"type": "string",
"title": "Roast Profile",
"enum": ["dark", "medium", "medium-light", "light"]
}
},
"required": ["name", "roast_date"]
}
ui.json
Create forms/register_coffee/ui.json:
{
"type": "SwipeLayout",
"options": {
"headerTitle": "Register Coffee",
"headerFields": ["name"]
},
"elements": [
{
"type": "Label",
"text": "<h2 style='background: #9C2C07; padding: 16px; border-radius: 8px; margin-bottom: 16px; color: cream'>Mmmm... Coffee...</h2>",
"options": {
"html": true
}
},
{
"type": "Control",
"scope": "#/properties/photo"
},
{
"type": "Control",
"scope": "#/properties/name"
},
{
"type": "Control",
"scope": "#/properties/origin"
},
{
"type": "Control",
"scope": "#/properties/roaster"
},
{
"type": "Control",
"scope": "#/properties/roast_profile"
},
{
"type": "Control",
"scope": "#/properties/roast_date"
}
]
}
Note: ODE extends JSONForms with types like
photo,gps,qr-code, and more. For details, see Form Specifications.
Part 2: Create the app
For v1, we'll keep the app simple — just a static HTML page. Imagine you'd welcome users or show instructions here.
Folder structure:
app/
├── index.html
├── style.css
└── coffee_cup.png
index.html
Create app/index.html:
<!DOCTYPE html>
<html>
<head>
<title>Coffee Tracker v1.0</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="container">
<h1>Coffee Tracker</h1>
<h2>v1.0</h2>
<p>A simple app for registering roasted coffee beans.</p>
<img src="coffee_cup.png" alt="Coffee cup" class="image">
<p><em>Start by registering a new coffee to begin tracking!</em></p>
</div>
</body>
</html>
style.css
Create app/style.css:
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: linear-gradient(135deg, #6B4423 0%, #9C2C07 100%);
color: #333;
margin: 0;
padding: 16px;
}
.container {
max-width: 600px;
margin: 0 auto;
background: white;
padding: 32px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
text-align: center;
}
h1 {
color: #9C2C07;
margin: 0 0 16px;
}
h2 {
color: #666;
margin: 0 0 24px;
font-size: 18px;
}
p {
color: #555;
line-height: 1.6;
margin: 12px 0;
}
.image {
max-width: 200px;
margin: 24px 0;
}
em {
color: #9C2C07;
}
Part 3: Bundle and upload
Create the zip archive
Ensure your folder structure is:
coffee_tracker-v1.0.0/
├── app/
│ ├── index.html
│ ├── style.css
│ └── coffee_cup.png
└── forms/
└── register_coffee/
├── schema.json
└── ui.json
On macOS/Linux:
zip -r coffee_tracker-v1.0.0.zip app/ forms/
On Windows (PowerShell):
Compress-Archive -Path app/, forms/ -DestinationPath coffee_tracker-v1.0.0.zip
Upload to Synkronus
Option 1: Web Portal
- Log in to Synkronus Portal
- Go to App Bundle page
- Upload
coffee_tracker-v1.0.0.zip
Option 2: CLI
./synk app-bundle upload ./coffee_tracker-v1.0.0.zip -a
Download the CLI from ODE releases.
Part 4: Test in Formulus
- Install Formulus on your device
- Configure the server (point to your Synkronus instance)
- Log in
- Go to Sync page → Update App Bundle
- Open the Coffee Tracker app
You should see your HTML page with the welcome message.
Troubleshooting
App doesn't appear after sync:
- Check Synkronus logs:
podman logs synkronus - Verify the upload succeeded in the Portal
- Ensure
app/andforms/are at the root of your zip file
Form validation errors:
- Check JSON syntax (use an online validator)
- Verify required fields in schema match the form
Photos not saving:
- Ensure device has camera permissions
- Check Synkronus attachments directory
Next steps
Congratulations! Your first custom_app is live. Ready to add more power?
In v2, we'll add:
- Follow-up form for "shots pulled"
- Dashboard showing registered coffees
- Data injection to link observations
- Formulus API integration
Reference
- JSONForms Spec — Full form configuration
- App Bundle Format — Technical bundle spec
- Synkronus Quickstart — Set up a test server
- Forum — Get help from the community
Coffee status: ☕ Registered. Ready to brew!