Create Apps
App Creator
The easiest way to build apps is by using freezr's App Creator, which helps you build, preview, and publish freezr apps directly from your browser. Every app lives under a project folder on your freezr server. You write plain HTML, CSS, and JS — there's no build step and no bundler required. (See below for writing in JSX / React)
How it works
Whichever way you create your apps, you should be aware of the way freezr ulls togehter pages and data. Each app contains one or more pages. A page is an HTML file plus optional CSS and JS resources. When a visitor loads the page, freezr injects your resources into a standard HTML skeleton and serves the result. You can define the reseources associated with each page in the app manifest. (Details below) Your JS can call freezr.query(), freezr.create(), and other API methods in your code — the global freezr object and its API are always available, as defined below.
App manifest
Every freezr app should ship a manifest.json read at install time. It describes the app's identity, the pages it serves, the data tables it needs, and the permissions it requests from the user or from other installed apps. (Simple apps can run without a manifest as freezr defaults to checking for the page namesake js css and html files.)
Metadata fields
| Field | Type | Description | |
|---|---|---|---|
| identifier | string | required | Reverse-domain app name, e.g. com.you.myapp. Also the folder name under users_freezr/<user>/apps/. |
| display_name | string | required | Human-readable name shown in the app list |
| description | string | optional | Short description shown in the app listing |
| version | string | optional | Semver version string |
| meta.only_use_collections_listed | boolean | optional | When true, freezr enforces the declared app_tables — writes to undeclared tables fail. Recommended for catching typos early. |
| manifest_url | string | optional | Canonical URL of the manifest zip file (if available). |
Pages
The pages object defines HTML pages served directly from the freezr server. Each entry is keyed by page name (e.g. index) and resolves to /apps/<app_id>/<page>. Page HTML files contain inner-body markup only — no <!DOCTYPE>, <html>, <head> or <body>. freezr wraps them with the document skeleton at render time.
| Field | Type | Description | |
|---|---|---|---|
| page_title | string | required | Browser tab title |
| html_file | string | required | Inner-body HTML filename |
| modules | array | optional | ES-module JS file(s) injected as <script type="module"> with the page nonce |
| script_files | array | optional | Non-module JS file(s) injected as plain <script> tags |
| css_files | array | optional | CSS file(s) linked in the page <head> |
| initial_query | object | optional | WIP. Pre-populates the page server-side using Mustache.js conventions |
App tables
The app_tables object declares the data tables (collections) the app uses. freezr creates them at install time and scopes them to the app's identifier: the actual table name is <app_id>.<table>, so com.you.notes's entries table is stored as com.you.notes.entries.
"app_tables": {
"entries": {
"strictly_Adhere_To_schema": false,
"field_names": {
"title": { "description": "Note title", "type": "String" },
"body": { "description": "Markdown body", "type": "Text" }
}
}
}
Tables are private to the app by default. Sharing with other apps requires a permissions declaration (below) plus the user granting it at install time.
Permissions
An app may request access to another installed app's tables, or to access the user's LLM or serveless instances, or to relax freezr's CSP policies, via the permissions object. Users can also give permission to other users to access specific data records or whole tables, and / or makle them publicly accessible. Each user must approve the permissions requested by apps at install time or later via the Account app. Granted permissions can be revoked at any time.
| Permission | Grants |
|---|---|
| read_all | Read all records in another app or user's table (broad scope) |
| write_all | Create / update records in another app or user's table |
| write_own | Read al and create records in another app or user's table, and be able to update the records that user/app has created. |
| share_records | Share specific records with other users on the same server. Once this permission is given to the app, the app is trusted to decide which persons to share with. |
| db_query | WIP allow apps or users to access records based on specific query criteria. |
| message_records | Send record-linked messages to other users |
| upload_pages | Create an html page which the app can reconstruct with specified js and css files. (This page was created with the upload_pages premission.) |
| allow_self_frames | Relax CSP to allow same-origin and blob: iframes |
| external_fetch | Relax connect-src to whitelisted external domains |
| external_scripts | Allow external scripts with SRI hashes |
| unsafe_eval | Allow eval() / new Function() |
| use_llm | Allow the app to use your llm API Key to ask your llm to perform any task. |
| use_serverless | Allow the app to use your serverless API Key to send code to e exeucited on the backl end. |
| use_3pFunction | Invoke a named local serverless function. This should only be used in development or for highly trusted code. It does actually run on the back end! |
The freezr global
Every app page has freezrApiV2.js injected before any app script. It defines a global freezr (and window.freezr) object exposing the full client API. Apps do not import it — they call it directly.
The global API is organised as:
| Namespace | Purpose |
|---|---|
| freezr.create / read / update / updateFields / delete / query | Record CRUD |
| freezr.collection(name) | Bound helper for a single table |
| freezr.publicquery | Query another user's public records |
| freezr.upload / getFileUrl / deleteFile | File operations |
| freezr.perms.* | Permission grants, record sharing, public file sharing |
| freezr.messages.* | Cross-user messaging linked to records |
| freezr.llm.* | LLM completions and image generation using the user's stored keys |
| freezr.serverless.* | Invoke and manage cloud (AWS Lambda) and local (trusted) functions |
| freezr.utils.* | Helpers: cookie reading, ping, manifest lookup, resource usage, path building |
| freezr.apiRequest | Low-level HTTP helper for custom endpoints |
freezr.initPageScripts hook is called once on window.load. Apps assign a function to it to run their boot code — this guarantees the API is ready and the page DOM is parsed.
All functions return a Promise. Most accept (primaryArgs, options) where options is an object with optional keys (appToken, host, owner_id, permission_name, requestee_app, etc.). Legacy callback-based methods (freezr.ceps.*, freezr.feps.*) exist only for backward compatibility and log a deprecation warning.
CRUD operations
The first argument is either a bare collection name (prefixed automatically with the calling app's identifier) or a full <app_id>.<table> reference when using requestee_app to access another app's data.
"notes") or full "com.other.app.items"._id is assigned server-side unless data_object_id is passed.const res = await freezr.create( 'notes', { title: 'Hello', body: 'First note' } ) // => { _id: 'abc123', ... }
await freezr.create('notes', data, { data_object_id: 'abc123', upsert: true })
_id.share_records / read_records permission).const note = await freezr.read('notes', 'abc123')
await freezr.read('notes', 'abc123', { owner_id: 'alice', permission_name: 'shared_notes' })
await freezr.update('notes', 'abc123', { title: 'Updated', body: 'New body' })
_id for single-record update, or a query object to update all matching records.await freezr.updateFields('notes', 'abc123', { done: true } )
await freezr.updateFields('notes', { archived: false }, { archived: true } )
await freezr.delete('notes', 'abc123')
await freezr.delete('notes', { archived: true } )
create, read, query, update, updateFields, delete pre-bound to name — convenient when a component only touches one table.const notes = freezr.collection('notes') await notes.create({ title: 'Hi' }) const all = await notes.query({})
Querying records
$where, $function, $accumulator, $expr) are rejected server-side.{ _date_modified: -1 }.const rows = await freezr.query( 'notes', { archived: false }, { sort: { _date_modified: -1 }, count: 20 } )
await freezr.publicquery({ owner: 'alice', app_table: 'com.you.blog.posts', count: 5 })
Files
Binary files are stored through the configured file-system adapter. File metadata (including _id and publicid) lives in the app's files collection, and the file body is addressed by a URL the browser fetches directly.
File object (from <input type="file">) or a Blob.const file = evt.target.files[0] const res = await freezr.upload(file) // => { _id, publicid, filename, size, ... }
// <img src={ freezr.getFileUrl(id) } />
// => /feps/userfiles/<app>/<user>/<id>
freezr.delete('files', fileId). The underlying storage is cleaned up by the file-system adapter.await freezr.deleteFile('img_001')
Permissions & sharing
{ name, granted, description, ... }. Also available: freezr.perms.isGranted(name) for a simple boolean check.const perms = await freezr.perms.getAppPermissions() if (await freezr.perms.isGranted('share_records')) { // show share UI }
Messages
Messages are small, record-linked notifications delivered across users on the same server (or across federated servers). Common uses: share invitations, activity notifications.
<app_id>.<table> containing the record.await freezr.messages.send({ recipient_id: 'bob', table_id: 'com.you.notes.entries', record_id: 'abc123', sharing_permission: 'shared_notes', contact_permission: 'contact_users' })
const msgs = await freezr.messages.getAppMessages() await freezr.messages.markRead(['m1', 'm2'])
LLM
freezr proxies LLM requests through the user's own keys (stored server-side in the account settings). The API abstracts Claude and OpenAI behind a single interface with streaming support via SSE.
{ role, content } for a conversation history.'Claude' or 'ChatGPT'.'sonnet', 'opus', 'mini'...), specific model id, token cap.streamBack: true, chunks are delivered via the callbacks as they arrive.'json' to auto-parse a JSON response. Incompatible with streamBack.const res = await freezr.llm.ask('Summarise', { context: 'You are a concise assistant', family: 'sonnet' }) console.log(res.response, res.meta.tokensUsed)
await freezr.llm.ask(prompt, { streamBack: true, onDelta: (t) => appendToUi(t) })
const img = await freezr.llm.generateImage( 'A cat in a wizard hat, watercolor', { size: '1024x1024' } ) // img.b64Data is a PNG base64 string
Serverless
Apps can invoke two kinds of server-side compute: cloud functions (AWS Lambda instances owned by the user) and local functions (Node modules loaded from users_3Pfunctions/, admin-installed). Both are addressed through the same freezr.serverless namespace and share a validation model: function names must match [a-zA-Z0-9._-]+ and the caller must hold a use_3pFunction permission.
| Function | Effect |
|---|---|
| freezr.serverless.invokeCloud(opts) | Invoke an already-deployed cloud function |
| freezr.serverless.invokeLocal(opts) | Invoke a local (trusted) function by name |
| freezr.serverless.createInvokeCloud(opts) | Create-if-missing and invoke |
| freezr.serverless.upsertCloud / updateCloud / deleteCloud | Lifecycle management for cloud functions |
| freezr.serverless.roleCreateCloud / deleteRole | Provision IAM role for a new function |
| freezr.serverless.upsertLocal / deleteLocal | Admin install / remove local functions (from a zip) |
| freezr.serverless.getAllLocalFunctions() | List installed local functions |
// invoke a cloud function const res = await freezr.serverless.invokeCloud({ function_name: 'com.you.myapp-summariser', payload: { text: '...' } }) // invoke a local function with a file attachment await freezr.serverless.invokeLocal({ function_name: 'zipBuilder', file: someBlob, metadata: { target: 'com.you.hello' } })
Microservices
freezr apps are generally client-side. If an app needs server-side compute — heavy processing, cron jobs (coming), or running other node modules, in untrusted build steps for example. There are two patterns, both reached via freezr.serverless.*.
The code itself would be packaged as a standard zip file with the serverless code in there. The serverless API also allows for limited reading of databases o populate the data to be transmitted to the server, and also receive data back.
Cloud (serverless) functions
Deployed to the user's own cloud account - AWS Lambda is the only chopice right now. The user's cloud credentials are stored in their freezr account profile. and the use_serverless permission allows the app access to the credentials.
- Lifecycle:
roleCreateCloudprovisions the IAM role;upsertCloud/updateCloud/deleteClouddeploy and remove code;createInvokeClouddoes upsert-and-invoke in one call. - Invocation:
invokeCloudcalls the Lambda with a JSON payload (or multipart form including the file).
Local (trusted) functions
Installed by the server admin from a zip uploaded into users_3Pfunctions/. Each function is a Node ES module whose default export is an async handler (payload, context) => result. The module runs in-process on the freezr server and has full access to the server's Node environment, so the admin trust model is analogous to installing an npm package.
- Install / update / delete: admin-only via
freezr.serverless.upsertLocal/deleteLocal. - Invocation: any user via
freezr.serverless.invokeLocal, gated by the app's declareduse_3pFunctionpermission. - Function names and permission names are restricted to
[a-zA-Z0-9._-]+. Zip extraction applies zip-slip protection.
Building React apps for freezr
React runs on freezr as a static bundle — JSX is transpiled at build time, and the output is plain HTML / CSS / ES-module JS that freezr can serve directly. A working end-to-end starter lives at github.com/salmanff/com.salmanff.reacthello.
Constraints
- No inline scripts. CSP blocks them. React's JSX event handlers (
onClick={...}) and module imports are fine; avoid libraries that inject<script>tags at runtime. - No HTML document wrapper. The served
index.htmlis inner-body only (typically<div id="root"></div>). The Vite config below strips the wrapper automatically. - ES modules. Manifest
modulesentries become<script type="module">. Keep build format'es'. - One CSS bundle. Set
build.cssCodeSplit: false— lazy-loaded CSS chunks with hashed names won't be listed in the manifest. - No
<head>ownership. Avoid libraries that assume they controldocument.titleor<meta>tags. - Client-side routing needs a manifest SPA flag not yet in the reference implementation — use hash routing for now.
Vite + React setup
The recommended layout is a single repository where the root is the installable freezr app and a react/ subfolder holds the source:
my-app/ index.html // built: inner-body HTML app.js // built: single ES-module bundle style.css // built: single CSS bundle manifest.json // built: with real filenames react/ // source package.json vite.config.js index.html // dev-only wrapper manifest.json // template src/ main.jsx App.jsx freezrStore.js styles.css
Mount React inside the freezr.initPageScripts callback in production, and immediately in dev (where there is no freezr global):
import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import App from './App.jsx' import './styles.css' function mount () { createRoot(document.getElementById('root')) .render(<StrictMode><App /></StrictMode>) } if (typeof window !== 'undefined' && window.freezr) { window.freezr.initPageScripts = mount } else { mount() }
Keep all freezr.* calls behind a thin wrapper module, with a localStorage fallback for dev mode:
// freezrStore.js const hasFreezr = () => typeof window !== 'undefined' && window.freezr && typeof window.freezr.query === 'function' export async function listNotes () { if (hasFreezr()) { return window.freezr.query('notes', {}, { sort: { _date_modified: -1 } }) } return readLocal() }
Vite config & packaging
A small Vite plugin does two things after the standard build: write an inner-body index.html, and rewrite manifest.json with the real built filenames:
import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import { copyFileSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' import { resolve } from 'node:path' function freezrBuild () { let projectRoot, repoRoot, distPath return { name: 'freezr-build', apply: 'build', configResolved (config) { projectRoot = config.root repoRoot = resolve(projectRoot, '..') distPath = resolve(projectRoot, config.build.outDir) }, closeBundle () { const files = readdirSync(distPath) const jsFile = files.find(f => f.endsWith('.js')) const cssFiles = files.filter(f => f.endsWith('.css')) copyFileSync(resolve(distPath, jsFile), resolve(repoRoot, jsFile)) cssFiles.forEach(c => copyFileSync(resolve(distPath, c), resolve(repoRoot, c))) writeFileSync(resolve(repoRoot, 'index.html'), '<div id="root"></div>\n') const manifest = JSON.parse(readFileSync( resolve(projectRoot, 'manifest.json'), 'utf8')) manifest.pages.index.modules = [jsFile] manifest.pages.index.css_files = cssFiles writeFileSync(resolve(repoRoot, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n') } } } export default defineConfig({ plugins: [react(), freezrBuild()], build: { outDir: 'dist', emptyOutDir: true, cssCodeSplit: false, rollupOptions: { input: resolve('src/main.jsx'), output: { entryFileNames: 'app.js', assetFileNames: '[name][extname]', format: 'es' } } } })
Add a pack script that builds and zips:
"scripts": { "dev": "vite", "build": "vite build", "pack": "vite build && cd .. && zip -r -X com.you.myapp.zip \ index.html app.js style.css manifest.json -x '*.DS_Store'" }
Then:
- Run
npm run packfrom insidereact/. - Log into your freezr server, open the Account app → Install Existing Apps → Upload.
- Upload the zip. The filename must match the app identifier exactly and files must be at the zip root.
- Your app is available at
/app/<app_id>.