Quick Start: Build Your First Custom App
Create and deploy a custom ODE application in 30 minutes using this step-by-step guide.
What You'll Need
Prerequisites:
- ODE server running (see Installation Guide)
- Node.js 20+ and npm 10+
- Basic knowledge of React/JavaScript
- Text editor (VS Code recommended)
Final Result:
- A working custom app with 1 form
- Deployed to your ODE server
- Testable on mobile device
Step 1: Create Project Structure
Create these exact folders and files:
my-app/
├── app/
│ ├── public/
│ │ └── app.config.json # App configuration
│ ├── src/
│ │ ├── App.js # Main app component
│ │ ├── theme.js # Theme generation
│ │ └── index.js # App entry point
│ ├── package.json # Dependencies
│ └── vite.config.js # Build configuration
└── forms/
└── survey/
├── schema.json # Form data structure
└── ui.json # Form layout
Step 2: Create App Configuration
File: app/public/app.config.json
{
"$schema": "https://ode.dev/schemas/app-config-v1.json",
"name": "My First App",
"version": "1.0.0",
"navigation": {
"tabs": ["Home", "Forms", "Sync"]
},
"theme": {
"light": {
"primary": "#1976d2",
"primaryLight": "#42a5f5",
"primaryDark": "#1565c0",
"onPrimary": "#ffffff",
"background": "#fafafa",
"surface": "#ffffff",
"onBackground": "#212121",
"onSurface": "#424242",
"divider": "#e0e0e0"
}
}
}
Step 3: Create Package.json
File: app/package.json
{
"name": "my-first-app",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"zip": "node scripts/build-zip.js",
"copy-forms": "node scripts/copy-forms.js"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.14.1",
"@mui/material": "^5.16.0",
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.0.0",
"vite": "^4.5.1",
"adm-zip": "^0.5.9"
}
}
Step 4: Create Vite Configuration
File: app/vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
base: './',
build: {
outDir: '../app-bundles/app',
emptyOutDir: true,
assetsDir: 'assets'
}
});
Step 5: Create Theme System
File: app/src/theme.js
import { createTheme } from '@mui/material/styles';
import appConfig from '../public/app.config.json';
export function buildTheme(mode = 'light') {
const colors = appConfig.theme[mode] ?? appConfig.theme.light;
return createTheme({
palette: {
mode,
primary: {
main: colors.primary,
light: colors.primaryLight,
dark: colors.primaryDark,
contrastText: colors.onPrimary,
},
background: {
default: colors.background,
paper: colors.surface,
},
text: {
primary: colors.onBackground,
secondary: colors.onSurface,
},
divider: colors.divider,
},
typography: {
fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif',
button: { textTransform: 'none' },
},
shape: {
borderRadius: 8,
},
});
}
const theme = buildTheme('light');
export default theme;
Step 6: Create Main App Component
File: app/src/App.js
import React from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import { ThemeProvider, CssBaseline } from '@mui/material';
import { createHashRouter, RouterProvider } from 'react-router-dom';
import theme from './theme';
import Home from './Home';
import Forms from './Forms';
import Sync from './Sync';
// Use HashRouter for compatibility with Formulus
const router = createHashRouter([
{ path: "/", element: <Home /> },
{ path: "/forms", element: <Forms /> },
{ path: "/sync", element: <Sync /> },
]);
function App() {
return (
<ThemeProvider theme={theme}>
<CssBaseline />
<RouterProvider router={router} />
</ThemeProvider>
);
}
export default App;
Step 7: Create Screen Components
File: app/src/Home.js
import React from 'react';
import { Container, Typography, Box, Button } from '@mui/material';
function Home() {
return (
<Container maxWidth="sm">
<Box sx={{ mt: 4, textAlign: 'center' }}>
<Typography variant="h4" component="h1" gutterBottom>
My First App
</Typography>
<Typography variant="body1" color="text.secondary" paragraph>
Welcome to your first ODE custom application!
</Typography>
<Button variant="contained" href="#/forms">
Start Survey
</Button>
</Box>
</Container>
);
}
export default Home;
File: app/src/Forms.js
import React from 'react';
import { Container, Typography, Box, Button } from '@mui/material';
function Forms() {
const openSurvey = () => {
// This will be handled by Formulus when running in the mobile app
if (window.formulus) {
window.formulus.openForm('survey', { mode: 'create' });
} else {
alert('Form opening only works in Formulus mobile app');
}
};
return (
<Container maxWidth="sm">
<Box sx={{ mt: 4 }}>
<Typography variant="h4" component="h1" gutterBottom>
Forms
</Typography>
<Typography variant="body1" paragraph>
Available forms for data collection.
</Typography>
<Button variant="contained" onClick={openSurvey} sx={{ mr: 2 }}>
New Survey
</Button>
<Button variant="outlined" href="#/">
Back to Home
</Button>
</Box>
</Container>
);
}
export default Forms;
File: app/src/Sync.js
import React from 'react';
import { Container, Typography, Box, Button } from '@mui/material';
function Sync() {
return (
<Container maxWidth="sm">
<Box sx={{ mt: 4 }}>
<Typography variant="h4" component="h1" gutterBottom>
Sync
</Typography>
<Typography variant="body1" paragraph>
Synchronization status and controls.
</Typography>
<Button variant="outlined" href="#/">
Back to Home
</Button>
</Box>
</Container>
);
}
export default Sync;
Step 8: Create App Entry Point
File: app/src/index.js
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
Step 9: Create Your First Form
File: forms/survey/schema.json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"title": "Simple Survey",
"properties": {
"name": {
"type": "string",
"title": "Full Name",
"minLength": 2
},
"email": {
"type": "string",
"title": "Email Address",
"format": "email"
},
"age": {
"type": "integer",
"title": "Age",
"minimum": 1,
"maximum": 120
},
"satisfaction": {
"type": "string",
"title": "Satisfaction Level",
"enum": ["very-satisfied", "satisfied", "neutral", "dissatisfied", "very-dissatisfied"]
}
},
"required": ["name", "email", "age"]
}
File: forms/survey/ui.json
{
"type": "VerticalLayout",
"elements": [
{
"type": "Control",
"scope": "#/properties/name"
},
{
"type": "Control",
"scope": "#/properties/email"
},
{
"type": "Control",
"scope": "#/properties/age"
},
{
"type": "Control",
"scope": "#/properties/satisfaction"
}
]
}
Step 10: Create Build Scripts
Create folder: app/scripts/
File: app/scripts/copy-forms.js
import fs from 'fs-extra';
import path from 'path';
const sourceDir = path.join(process.cwd(), '..', 'forms');
const targetDir = path.join(process.cwd(), 'public', 'forms');
fs.copy(sourceDir, targetDir)
.then(() => console.log('✓ Forms copied successfully'))
.catch(err => console.error('✗ Error copying forms:', err));
File: app/scripts/build-zip.js
import AdmZip from 'adm-zip';
import fs from 'fs';
import path from 'path';
const appBundleDir = path.resolve(__dirname, '../../app-bundles');
const appOutDir = path.resolve(__dirname, '../..', 'app-bundles', 'app');
const formsSrcDir = path.resolve(__dirname, '../../forms');
const version = JSON.parse(fs.readFileSync(path.resolve(__dirname, '../package.json'), 'utf8')).version;
const zipName = `bundle-v${version}.zip`;
const zipPath = path.join(appBundleDir, zipName);
function buildZip() {
console.log('📦 Building app bundle ZIP...\n');
const tempDir = fs.mkdtempSync(path.join(appBundleDir, 'tmp-'));
const tempAppDir = path.join(tempDir, 'app');
fs.mkdirSync(tempAppDir, { recursive: true });
try {
// Copy forms to app/forms/
const formsDest = path.join(tempAppDir, 'forms');
fs.mkdirSync(formsDest, { recursive: true });
if (fs.existsSync(formsSrcDir)) {
fs.copySync(formsSrcDir, formsDest);
console.log('✓ Forms copied to app/forms/');
}
// Copy built app files
if (fs.existsSync(appOutDir)) {
fs.copySync(appOutDir, tempAppDir);
console.log('✓ App files copied to app/');
}
// Create ZIP
const zip = new AdmZip();
function addDirectoryToZip(dirPath, zipPathPrefix) {
const items = fs.readdirSync(dirPath);
for (const item of items) {
const fullPath = path.join(dirPath, item);
const stat = fs.statSync(fullPath);
const zipEntryPath = path.join(zipPathPrefix, item).replace(/\\/g, '/');
if (stat.isDirectory()) {
addDirectoryToZip(fullPath, zipEntryPath);
} else {
zip.addFile(zipEntryPath, fs.readFileSync(fullPath));
}
}
}
addDirectoryToZip(tempAppDir, 'app');
zip.writeZip(zipPath);
const stats = fs.statSync(zipPath);
console.log(`\n✅ Bundle created: ${zipPath}`);
console.log(`📊 Size: ${(stats.size / 1024).toFixed(2)} KB`);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
}
buildZip();
Step 11: Build and Deploy
11.1 Install Dependencies
cd app
npm install
11.2 Build Your App
# Copy forms and build the app
npm run copy-forms
npm run build
# Create deployment bundle
npm run zip
Expected Output:
✓ Forms copied successfully
✓ App files copied to app/
📦 Building app bundle ZIP...
✓ Forms copied to app/forms/
✓ App files copied to app/
✅ Bundle created: /path/to/my-app/app-bundles/bundle-v1.0.0.zip
📊 Size: 45.23 KB
11.3 Deploy to ODE Server
Option A: Via CLI (Recommended)
# Install Synkronus CLI (if not already installed)
go install github.com/OpenDataEnsemble/ode/synkronus-cli/cmd/synkronus@latest
# Login to your server
synk login --url http://localhost:8080 --username admin
# Upload and activate your app
synk app-bundle upload app-bundles/bundle-v1.0.0.zip --activate
Option B: Via Web Portal
- Open browser:
http://localhost:8080/portal - Login with admin credentials
- Navigate to "App Bundles"
- Click "Upload Bundle"
- Select:
app-bundles/bundle-v1.0.0.zip - Click "Upload" then "Activate"
Step 12: Test on Mobile Device
12.1 Install Formulus App
Android:
# Install APK via ADB
npm run android
# Or install from Google Play Store (search "Formulus")
12.2 Configure App
- Open Formulus app
- Enter server URL:
http://your-server-ip:8080 - Login with your credentials
- Wait for initial sync
12.3 Test Your Custom App
- In Formulus, tap "Custom Apps"
- Select "My First App"
- Navigate to "Forms" tab
- Tap "New Survey"
- Fill out the form and submit
- Check "Sync" to upload data
🎉 Congratulations!
You've successfully:
- ✅ Created a custom ODE application
- ✅ Built and packaged it for deployment
- ✅ Deployed it to your ODE server
- ✅ Tested it on a mobile device
Next Steps
- Add more forms: Create additional form folders in
forms/ - Customize theme: Modify colors in
app.config.json - Add features: Extend React components in
src/ - Update version: Change version in
package.jsonand rebuild
Troubleshooting
Build fails:
- Ensure all dependencies installed:
npm install - Check forms folder exists and contains valid JSON
Upload fails:
- Verify server is running:
http://localhost:8080 - Check admin credentials are correct
- Ensure bundle file exists and is not corrupted
App not showing on mobile:
- Check server URL in Formulus app
- Verify bundle was activated successfully
- Wait for sync to complete (pull to refresh)
Form not opening:
- Ensure form files are valid JSON
- Check form name matches in
openForm()call - Verify form exists in
forms/directory