@levo-so/core
v0.1.82
Published
Levo common utilities
Readme
@levo-so/core
The foundational JavaScript/TypeScript client for the Levo platform. Every Levo-powered application — whether a website, web app, or backend service — starts here. It handles authentication, dynamic form collections, blog content, media, and all communication with the Levo API.
Table of Contents
Installation
# pnpm
pnpm add @levo-so/core
# npm
npm install @levo-so/core
# bun
bun add @levo-so/coreESM only. Requires a bundler or Node 18+.
Concepts
Before jumping into code, a few Levo concepts worth understanding:
Workspace — Everything in Levo lives inside a workspace. Think of it as your project or organization account. Your workspace ID is visible in the Levo dashboard under Settings, and looks like W1A2B3C4 (a W followed by 7 uppercase characters). All API requests are scoped to this workspace.
Collections — Levo's dynamic form and content system. A collection is a schema — it defines fields, their types, validation rules, and how they should be rendered (the widget). You submit data into a collection, and can query that data back. They power everything from contact forms to complex multi-step data entry flows.
Membership — Levo's built-in user identity and authentication system. It supports multiple sign-in methods (OAuth, magic links, OTP, passwords) and manages sessions natively, so you don't need a separate auth provider.
APP_MODE vs NODE_ENV — These are distinct. NODE_ENV is the standard build-time environment ("development" or "production") and affects internal behavior like log verbosity and whether errors are shipped to Levo APM. APP_MODE is a Levo-specific concept for your deployment context — for example, "production", "staging", or "preview". A staging server often runs with NODE_ENV=production (optimized build) but APP_MODE=staging (staging data, restricted features). @levo-so/core stores it but doesn't act on it — it's available via client.APP_MODE for your application logic.
Setup
Create a single client instance and share it across your application:
import { createLevoClient } from "@levo-so/core";
const client = createLevoClient({
workspace: "W1234ABC", // Your workspace ID from the Levo dashboard
apiUrl: "https://public-api.levo.so", // Levo's public API — use as-is unless self-hosting
NODE_ENV: process.env.NODE_ENV,
APP_MODE: process.env.APP_MODE, // e.g. "production", "staging", "preview"
});In a browser context, the client automatically reads the current page URL, title, and referrer and attaches them to every request — this is used for analytics and debugging in the Levo dashboard. You don't need to configure this manually.
For SPAs where the URL changes without a full page load, call client.updatePageContext({ url, title }) on navigation.
Modules
Membership
Levo handles identity for your users. The membership module covers the full lifecycle: signing up, signing in through any method, managing the session, and updating profile data. Sessions are cookie-based and managed by Levo automatically.
Sign-in methods available:
- OAuth — Redirect users to Google, LinkedIn, or Microsoft. Call
getOAuthURL()to get the provider-specific redirect URL, then handle the callback on your end. - Magic link — Send a one-click login email with
requestMagicLink(). The user clicks the link and lands on your callback URL. - OTP — Send a one-time code via email or WhatsApp with
requestOtp(), then verify it withsignInWithOtp(). - Password — Traditional email + password auth via
signInWithPassword()andsignUpWithPassword().
// Get OAuth redirect URLs for all providers
const urls = await client.membership.getOAuthURL();
window.location.href = urls.google;
// OTP flow
await client.membership.requestOtp({ email: "[email protected]", type: "email" });
const account = await client.membership.signInWithOtp({ email, otp: "123456" });
// Password flow
const account = await client.membership.signInWithPassword({ email, password });
// Check current session — returns null if not signed in
const me = await client.membership.getMe();
// Update profile
await client.membership.updateMe({ first_name: "Jane", username: "jane" });
await client.membership.signOut();Collection
A collection is a schema — it tells you what fields exist, what type they are, and how they should be presented (the widget). Your application uses this schema to render forms, validate input, and submit data. Levo stores the submissions and makes them queryable.
Collections also support a draft workflow: save in-progress data before the user is ready to submit, let them return and continue, then finalize with a submit call. This is useful for multi-step forms or forms where users might leave and come back.
// Fetch the schema for a collection — use this to render your form
const { content } = await client.collection.getAllCollections();
const schema = content.data.find(c => c.key === "my-form");
// Submit completed form data
await client.collection.saveCollectionData({
collection_id: schema.id,
data: { name: "Alice", email: "[email protected]", message: "Hello" },
});
// Query existing submissions (e.g. to show a user their past entries)
const { content } = await client.collection.getCollectionContentList({
collection_key: "my-form",
page: 1,
limit: 20,
where: { status: { equals: "approved" } },
});
// Draft → submit (for multi-step or save-and-continue flows)
const draft = await client.collection.saveDraftEntry("my-form", partialData);
await client.collection.editDraftEntry("my-form", draft.content.data.id, moreData);
await client.collection.submitDraftEntry("my-form", { id: draft.content.data.id });Blog
Pull blog content from any Levo-hosted blog into your application. Posts come back with everything you'd expect: HTML and structured JSON content, cover images (with responsive variants at multiple widths), reading time, authors, tags, categories, and full SEO metadata.
The blog key is the identifier for your specific blog, visible in the Levo dashboard.
// All published posts, newest first
const { content } = await client.blog.getAllBlogs("my-blog-key", {
page: 1,
limit: 10,
sort: { published_at: "desc" },
where: { status: { equals: "published" } },
});
// content.data → IPost[]
// content.meta.total → total count for pagination
// A single post by its slug
const { content } = await client.blog.getSingleBlog("my-blog-key", "my-post-slug");
// content.data → IPost (includes content.html, content.json, cover_image, og_image, etc.)Media
Upload files to Levo's media library and get back fully resolved objects ready to use in your UI. Images are automatically processed into responsive variants from 320w up to 2560w, which you can use directly in <img srcset> or <picture> elements.
const formData = new FormData();
formData.append("files", file);
const { content } = await client.media.mediaBulkUpload(formData);
// content.data[0].location → full CDN URL
// content.data[0].srcset → { "320w": "...", "640w": "...", ... }
// content.data[0].metadata → { mimetype, size }Error Handling
Every module method can throw. Rather than catching raw WretchError or TypeError objects and trying to parse them, use getLevoError() to normalize everything into a predictable LevoError shape regardless of what went wrong.
import { getLevoError } from "@levo-so/core";
try {
await client.collection.saveCollectionData({ collection_id, data });
} catch (error) {
const err = getLevoError(error);
// err.code — machine-readable error code (e.g. "VALIDATION_ERROR")
// err.message — human-readable description
// err.status — HTTP status code
if (err.hasFieldErrors) {
// When the API returns field-level validation failures,
// wire these directly into your form library's error state
err.fieldErrors.forEach(({ param, message }) => {
form.setError(param, { message });
});
}
}getLevoError() covers all failure modes: HTTP 4xx/5xx errors with JSON bodies, network failures (offline, CORS, DNS), request timeouts, and already-normalized LevoError instances (passed through as-is, so it's safe to call twice).
A note on displaying errors: in modals and forms, prefer showing err.message inline near the action that failed rather than using a toast. Toasts are easy to miss; inline errors give users immediate, contextual feedback.
Querying & Filtering
All list methods accept a query object for filtering, sorting, pagination, and field selection. This is the same query shape across all modules — blog, collections, media.
import type { LevoQuery } from "@levo-so/core";
const query: LevoQuery.FindQuery = {
page: 1,
limit: 20,
sort: { created_at: "desc" },
// Select only the fields you need
select: { _id: true, title: true, published_at: true },
// Filtering with AND/OR — nestable arbitrarily
where: {
AND: [
{ status: { equals: "published" } },
{ created_at: { gte: "2024-01-01" } },
{ OR: [
{ tag: { contains: "news" } },
{ tag: { contains: "updates" } },
]},
],
},
};Comparison operators: equals, not, in, not_in, gt, gte, lt, lte, contains, starts_with, ends_with, between, is_empty, is_not_empty, has, within (geo radius)
Logical operators: AND, OR — nest at any depth.
Utilities
getLevoError(error) — Normalize any thrown value into a LevoError. See Error Handling.
formatImagePath(path) — URI-encodes the filename portion of a path. Use this when constructing image URLs that may contain spaces or special characters.
listToColumn(arr) — Converts ["name", "email"] into { name: "name", email: "email" }. Useful for building field selection objects programmatically.
Type utilities — Prettify<T>, Mandatory<T, K>, and DeepPartial<T> are exported from the package for use in consuming packages that build on top of @levo-so/core.
