Build with the Lacspace ecosystem
Search it, filter by kit, switch your package manager, and copy real recipes — every snippet is accurate API from the packages themselves.
Getting started
Every package lives on npm under the @lacspace org — zero-dependency, isomorphic, dual ESM + CJS with types. Install only what you need.
Install
Add exactly the packages you use — there's no core runtime to pull in first.
npm i @lacspace/validate @lacspace/money @lacspace/jwtFirst calls
Import named functions and go. Nothing is global; tree-shaking keeps your bundle tiny.
import { slugify } from "@lacspace/slugify";
import { money } from "@lacspace/money";
slugify("Hello, World! — 2026"); // "hello-world-2026"
money(19.99, "USD").format(); // "$19.99"Requirements
Node 18+ for most packages. Web-Crypto packages (crypto, jwt, otp, webauthn, signed-url) want Node 20+, where globalThis.crypto is available by default — and it's always there on the edge and in browsers.
How it's built
Four rules hold across the whole catalog — which is what makes 197 packages feel like one standard library.
The four rules
- Zero runtime dependencies — no transitive supply chain.
npm view @lacspace/seo dependenciesreturns{}. - Isomorphic — one build runs in Node, browsers and edge, with no Node-only globals unless the package is explicitly server-side.
- Dual ESM + CJS — built with tsup;
importandrequireboth work, each with.d.tstypes. - Web Crypto, never hand-rolled — real AES-256-GCM, PBKDF2 and HMAC via SubtleCrypto.
Auth & security
A complete, production-shaped login surface built from small packages — hashing, brute-force lockout, signing a session, rate limiting, 2FA and API keys.
Register — hash the password
PBKDF2-HMAC-SHA256 (OWASP iterations) → a portable PHC string you store as-is. Gate weak passwords with strength().
import { hash, strength } from "@lacspace/password";
export async function register(email: string, password: string) {
if (strength(password).score < 2) {
throw new Error("Please choose a stronger password.");
}
const passwordHash = await hash(password);
// "$pbkdf2-sha256$i=600000$<salt>$<hash>" — store this exact string
await db.users.create({ email, passwordHash });
}Log in — verify, lock out, sign a token
lock stops brute force with exponential backoff; password verifies; jwt mints the session with strict claims.
import { verify, hash, needsRehash } from "@lacspace/password";
import { lockout } from "@lacspace/lock";
import { sign } from "@lacspace/jwt";
const guard = lockout({ maxAttempts: 5, baseDelayMs: 60_000, maxDelayMs: 3_600_000 });
export async function login(email: string, password: string) {
const status = await guard.check(email);
if (status.locked) throw new Error("Too many attempts — try again later.");
const user = await db.users.findByEmail(email);
const ok = user && (await verify(password, user.passwordHash));
if (!ok) {
const s = await guard.record(email);
throw new Error(s.locked ? "Account locked." : `${s.remaining} attempts left`);
}
await guard.reset(email);
if (needsRehash(user.passwordHash)) {
await db.users.update(user.id, { passwordHash: await hash(password) });
}
return sign({ sub: user.id, role: user.role }, process.env.JWT_SECRET!, {
expiresIn: 3600, issuer: "lacspace",
});
}Verify the session token
The mirror image — a typed JwtError you branch on.
import { verify, JwtError } from "@lacspace/jwt";
export async function currentUser(token: string) {
try {
const claims = await verify(token, process.env.JWT_SECRET!, { issuer: "lacspace" });
return { id: claims.sub, role: claims.role };
} catch (e) {
if (e instanceof JwtError) return null; // "expired" | "signature" | …
throw e;
}
}Rate-limit the endpoint
Framework-agnostic — fixed window, sliding window or token bucket over any store.
import { rateLimit } from "@lacspace/rate-limit";
const limiter = rateLimit({ limit: 10, windowMs: 60_000, algorithm: "sliding" });
export async function POST(req: Request) {
const ip = req.headers.get("x-forwarded-for") ?? "anon";
const { success, retryAfter } = await limiter.check(ip);
if (!success) {
return new Response("Too many requests", {
status: 429,
headers: { "retry-after": String(retryAfter) },
});
}
// …proceed to login()
}Two-factor with TOTP
Google-Authenticator compatible. Generate a secret, show a QR, verify the 6-digit code.
import { generateSecret, keyuri, verifyTotp } from "@lacspace/otp";
// enrol
const secret = generateSecret(); // store encrypted per user
const uri = keyuri({ secret, label: user.email, issuer: "Lacspace" });
// render the uri as a QR code for the authenticator app
// verify at login
if (!verifyTotp(codeFromUser, secret)) {
throw new Error("Invalid 2FA code.");
}API keys for a public API
Prefixed, high-entropy keys; store only the SHA-256. Show the raw key once, verify in constant time.
import { generateApiKey, verifyApiKey } from "@lacspace/apikey";
// on create — return `key` to the user ONCE, store the rest
const { key, hash, prefix, last4 } = await generateApiKey({ prefix: "lac_live" });
await db.keys.create({ hash, prefix, last4, userId });
// on each request
const record = await db.keys.findByPrefix(presentedPrefix);
if (!record || !(await verifyApiKey(presentedKey, record.hash))) {
return new Response("Unauthorized", { status: 401 });
}Encrypt sensitive fields
Authenticated AES-256-GCM over Web Crypto — encrypt a value before it touches your database.
import { generateKey, encrypt, decrypt } from "@lacspace/crypto";
const key = process.env.DATA_KEY!; // a 256-bit base64url key
const blob = await encrypt("card: 4242…", key); // "v1:<iv>:<ciphertext+tag>"
const plain = await decrypt(blob, key); // "card: 4242…"SEO, done right
Configure-once. defineSite() takes your brand a single time, then every page's title, canonical, Open Graph, Twitter card and JSON-LD is a one-liner. This very site runs on it.
Set your brand once
import { defineSite } from "@lacspace/seo";
export const site = defineSite({
name: "Acme",
url: "https://acme.com",
ogImage: "/og", // dynamic social cards, zero design work
twitter: "acmehq",
searchUrl: "https://acme.com/search?q={search_term_string}",
});One-line metadata + JSON-LD per page
Spread the metadata, render the JSON-LD. site.faq(), site.softwareApp(), site.article(), site.product() and site.collection() each return matching rich-results schema.
import { site } from "@/lib/seo";
const { metadata, jsonLd } = site.page({ title: "Pricing", path: "/pricing" });
export { metadata }; // canonical + OG + Twitter + og:image, all filled in
export default function Pricing() {
return (
<>
<script type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
{/* … */}
</>
);
}Sitemap, robots & OG image
Three more small files — and dynamic 1200×630 social cards from @lacspace/og.
import { toNextSitemap } from "@lacspace/sitemap";
export default () => toNextSitemap([{ loc: "https://acme.com/", priority: 1 }]);
import { toNextRobots } from "@lacspace/robots";
export const robots = () => toNextRobots({ groups: [{ userAgent: "*", allow: ["/"] }],
sitemap: "https://acme.com/sitemap.xml" });
// app/og/route.tsx
import { ImageResponse } from "next/og";
import { ogCard } from "@lacspace/og";
export const runtime = "edge";
export const GET = (req: Request) => new ImageResponse(
ogCard({ title: new URL(req.url).searchParams.get("title") ?? "Acme", logo: "A" }),
{ width: 1200, height: 630 });Backend building blocks
Server plumbing without the servers — typed config, SMTP, webhooks, signed links and PDFs.
Typed environment variables
Validate process.env at boot — a missing or malformed var fails fast with a clear message.
import { createEnv, str, port, url, bool, oneOf } from "@lacspace/env";
export const env = createEnv({
NODE_ENV: oneOf(["development", "production", "test"], { default: "development" }),
PORT: port({ default: 3000 }),
DATABASE_URL: url(),
SMTP_HOST: str(),
DEBUG: bool({ default: false }),
});Send email over SMTP
A tiny SMTP client with no dependency tree, plus bulletproof, dark-mode-aware HTML from blocks.
import { createMailer, presets } from "@lacspace/mailer";
import { welcomeEmail, render } from "@lacspace/email-templates";
const mail = createMailer(
presets.hostinger({ user: "no-reply@acme.com", pass: process.env.SMTP_PASS! }),
);
await mail.send({
to: "customer@example.com",
subject: "Welcome to Acme ✨",
html: render(welcomeEmail({ name: "Ada", ctaUrl: "https://acme.com/start" })),
});Receive webhooks — exactly once
Verify the signature against the raw body, then make the handler idempotent so a provider retry can't double-charge.
import { verify } from "@lacspace/webhooks";
import { idempotent } from "@lacspace/idempotency";
export async function POST(req: Request) {
const raw = await req.text(); // raw body, not await req.json()
const r = await verify(raw, req.headers.get("webhook-signature"), {
secret: process.env.WEBHOOK_SECRET!,
toleranceSec: 300, // reject replays older than 5 min
});
if (!r.valid) return new Response(`rejected: ${r.reason}`, { status: 400 });
const event = JSON.parse(raw);
const { value, replayed } = await idempotent(event.id, () => fulfil(event));
return Response.json({ ok: true, replayed });
}Signed, expiring links
Tamper-proof magic links and download URLs over HMAC. verify() never throws.
import { sign, verify } from "@lacspace/signed-url";
const token = await sign({ userId: 42, action: "reset" }, {
secret: process.env.LINK_SECRET!,
expiresIn: 3600,
});
const link = `https://acme.com/reset?t=${token}`;
const r = await verify<{ userId: number }>(token, { secret: process.env.LINK_SECRET! });
if (r.valid) grantReset(r.data.userId);
else console.log(r.reason); // "malformed" | "bad-signature" | "expired"Generate a real PDF invoice
Real PDFs with zero dependencies and no headless browser.
import { invoice } from "@lacspace/pdf";
const bytes = invoice({
brand: "Acme", number: "INV-1024", date: "2026-09-05",
from: { name: "Acme Inc.", email: "billing@acme.com" },
to: { name: "Client Ltd.", email: "ap@client.com" },
items: [{ description: "Consulting", quantity: 10, rate: 120 }],
currency: "$", taxRate: 13,
});
return new Response(bytes, {
headers: { "content-type": "application/pdf",
"content-disposition": 'attachment; filename="INV-1024.pdf"' },
});Data, money & validation
The pieces every app repeats — validation, money that never loses a cent, and spreadsheet I/O.
Validate untrusted input
zod-style schemas with parse/safeParse and full inference — plus coerce, because FormData and query strings are all strings.
import { v, type Infer } from "@lacspace/validate";
const Signup = v.object({
email: v.string().email().toLowerCase(),
age: v.coerce.number().int().min(18), // "21" → 21
role: v.enum(["admin", "user"]).default("user"),
});
type Signup = Infer<typeof Signup>;
const result = Signup.safeParse(await req.json());
if (!result.success) return Response.json(result.error, { status: 422 });Handle money without float bugs
Integer minor units, so a cent never disappears. allocate() splits a total so the remainder is distributed, not lost.
import { money } from "@lacspace/money";
const price = money(19.99, "USD"); // 1999 minor units, exact
price.multiply(3).format(); // "$59.97"
money(10, "USD").allocate([1, 1, 1]) // split a bill three ways
.map((m) => m.format()); // ["$3.34", "$3.33", "$3.33"] — sums to $10.00
money(9.99, "USD").add(money(1, "EUR")); // throws: currency mismatchExport to Excel & parse CSV
Write real .xlsx with no headless browser; parse RFC-4180 CSV correctly.
import { jsonToXlsx } from "@lacspace/xlsx";
import { parse } from "@lacspace/csv";
const bytes = jsonToXlsx([
{ name: "Ada", signups: 12, active: true },
{ name: "Alan", signups: 7, active: false },
]);
const rows = parse<{ id: string; qty: string }>(csvText);Commerce & payments
A whole marketplace backend, composed from small pieces — cart, order, tax, shipping, invoice, payouts and Nepal's payment gateways. Every amount is an integer in minor units (paisa / cents), so there are no floating-point money bugs, and the order and delivery flows are explicit state machines.
Build a marketplace checkout
Cart → shipping quote → an immutable order with snapshotted line prices. Each package does one thing; they compose into a checkout.
import { createCart, addItem } from "@lacspace/cart";
import { quoteShipping } from "@lacspace/shipping";
import { createOrder, orderNumber } from "@lacspace/order";
// 1. Cart — prices in paisa (integer minor units, never floats)
let cart = createCart({ currency: "NPR" });
cart = addItem(cart, { id: "NP-1", name: "Dhaka Topi", price: 120000, qty: 2 });
// 2. Cheapest shipping method for the destination + basket
const methods = [
{ id: "std", label: "Standard", strategy: "weight", freeOver: 500000,
bands: [{ min: 0, max: 1000, cost: 8000 }, { min: 1000, cost: 12000 }] },
];
const [ship] = quoteShipping(methods, { weight: 500, subtotal: 240000 });
// 3. Freeze it into an immutable order — line prices are snapshotted here,
// so a later catalog price change never mutates a placed order.
let order = createOrder({
number: orderNumber(1042), // "ORD-20260906-1042"
currency: "NPR",
customer: { id: "cus_1", name: "Sita" },
lines: cart.items.map((i) => ({
id: i.id, sku: i.id, name: i.name, unitPrice: i.price, qty: i.qty, taxRate: 0.13,
})),
shipping: ship.cost,
});
// order.totals -> { subtotal, discount, tax, shipping, total } — all paisaTrack an order through its lifecycle
A validated state machine — illegal jumps throw, and every step is appended to a timestamped history you can show the customer or an auditor.
import { createOrder, transition, canRefund } from "@lacspace/order";
let order = createOrder({ number: "ORD-1042", currency: "NPR", lines });
order = transition(order, "placed");
order = transition(order, "paid", { note: "eSewa ref 9x..." });
order = transition(order, "processing");
order = transition(order, "shipped", { note: "Pathao CN-88" });
order = transition(order, "delivered");
// transition(order, "pending") would throw OrderError — the flow is enforced
order.history; // [{ status: "placed", at }, { status: "paid", at, note }, ...]
canRefund(order); // true — still refundable until "completed"Reserve stock, then handle a return
Reserve at checkout (it throws before it ever oversells), then compute a partial refund with tax apportioned and get the exact list to restock.
import { createStock, reserve, commit, restock } from "@lacspace/inventory";
import { createReturn, refundAmount, restockItems } from "@lacspace/refund";
// Oversell-proof: reserve holds stock, commit ships it
let stock = createStock({ onHand: 10 });
stock = reserve(stock, 2); // throws InventoryError rather than going negative
stock = commit(stock, 2); // on payment
// A customer returns 1 of the 2 units
const rma = createReturn({ orderId: "ORD-1042", items: [
{ lineId: "l1", sku: "NP-1", qty: 1, unitPrice: 120000, taxRate: 0.13, restock: true },
] });
const money = refundAmount(rma.items, { restockingPct: 0.10 });
// { subtotal: 120000, tax: 15600, restockingFee: 12000, shipping: 0, total: 123600 }
for (const { sku, qty } of restockItems(rma.items)) {
stock = restock(stock, qty); // put returned units back
}Split a multi-vendor payout
One commission resolver, netting per seller, and a double-entry ledger that always balances — the money side of a marketplace, exactly.
import { commission } from "@lacspace/commission";
import { settle, payouts } from "@lacspace/settlement";
import { createLedger, post, trialBalance } from "@lacspace/ledger";
// Platform take-rate on a 2,400 order (8%)
const fee = commission({ type: "percent", rate: 0.08 }, 240000); // 19200 paisa
// Net each account, then list who is owed a payout
const ledger = settle([
{ account: "seller_1", amount: 240000 - fee },
{ account: "platform", amount: fee },
]);
payouts(ledger); // [{ account: "seller_1", amount: 220800 }, { account: "platform", amount: 19200 }]
// Mirror it into a double-entry book that always sums to zero
let book = createLedger();
book = post(book, { memo: "Order #1042", lines: [
{ account: "cash", debit: 240000 },
{ account: "sales", credit: 220800 },
{ account: "fees", credit: 19200 },
] });
trialBalance(book); // balances to 0Issue an invoice
Build the invoice model — per-line and total maths, tax grouped by rate, a sequential number — then hand the rows to @lacspace/pdf or @lacspace/xlsx to render.
import { createInvoice, invoiceNumber, recordPayment, renderRows } from "@lacspace/invoice";
let inv = createInvoice({
number: invoiceNumber(123), // "INV-2026-000123"
currency: "NPR",
seller: { name: "Lacspace", taxId: "PAN123" },
buyer: { name: "Sita Rai" },
lines: [{ description: "Dhaka Topi", qty: 2, unitPrice: 120000, taxRate: 0.13 }],
});
inv.taxSummary; // [{ rate: 0.13, net: 240000, tax: 31200 }]
inv = recordPayment(inv, 271200); // status -> "paid", balanceDue -> 0
const { columns, rows } = renderRows(inv); // ready for @lacspace/pdf or @lacspace/xlsxTake an eSewa payment
Build the signed redirect form, then verify the signature AND the amount against your own order — never trust the redirect alone.
import { buildForm, verifyResponse } from "@lacspace/esewa";
// 1. Server-side: build the signed form and auto-POST it to eSewa
const form = buildForm({
amount: "2400", // rupees
productCode: "EPAYTEST",
successUrl: "https://shop.np/esewa/success",
failureUrl: "https://shop.np/esewa/failure",
secret: process.env.ESEWA_SECRET!,
});
// render form.action + form.fields as an auto-submitting <form>
// 2. On return: verify signature + amount against the authoritative order
const res = verifyResponse(base64Data, process.env.ESEWA_SECRET!);
if (res.verified && res.totalAmount === "2400") {
// safe to mark the order paid
}Take a Khalti payment
Initiate a payment (amount in paisa), redirect, then confirm server-to-server with a lookup before you fulfil.
import { initiate, lookup } from "@lacspace/khalti";
const opts = { secretKey: process.env.KHALTI_SECRET!, env: "test" as const };
// 1. Start the payment — amount in paisa
const { payment_url, pidx } = await initiate({
returnUrl: "https://shop.np/khalti/return",
websiteUrl: "https://shop.np",
amount: 240000,
purchaseOrderId: "ORD-1042",
purchaseOrderName: "Colour order",
}, opts);
// redirect the buyer to payment_url
// 2. Confirm before fulfilling — the redirect alone is not proof of payment
const status = await lookup(pidx, opts); // "Completed" | "Pending" | "Refunded" | ...Connect IPS & Fonepay
The bank-rail gateways: Connect IPS signs its token with your RSA key; Fonepay signs the request DV with HMAC-SHA512.
import { signToken, buildForm } from "@lacspace/connectips";
import { buildRedirect, verifyResponse } from "@lacspace/fonepay";
// Connect IPS — sign the transaction token with your RSA (PKCS#8) private key
const token = await signToken(
{ merchantId, appId, appName, txnId: "ORD-1042", txnDate, txnAmount: "2400", referenceId, remarks },
process.env.CONNECTIPS_PRIVATE_KEY!, // PKCS#8 PEM
);
const cipsForm = buildForm({ /* merchant fields */ }); // POST to Connect IPS
// Fonepay — HMAC-SHA512 signed redirect; verify the response DV on return
const fp = buildRedirect(
{ amt: "2400", pid: "ORD-1042", prn: "ref_1", ru: returnUrl, merchantCode },
process.env.FONEPAY_SECRET!,
);
const ok = await verifyResponse(query, process.env.FONEPAY_SECRET!);Automate delivery status
Verify an inbound Pathao webhook, normalize it to a canonical status, and advance the order — no more manual admin clicking.
Do the payment packages hold my secret keys?
No. You pass secrets in per call (or from your own env). The packages never store, log or transmit them anywhere except the gateway's own endpoint over HTTPS.
Are these tied to Colour Nepal or any one store?
No — they're generic gateway toolkits. They handle the signing, form-building and server-side verification every Nepali merchant integration needs, against your own order amount.
import { verifyPathaoWebhook, parsePathaoWebhook } from "@lacspace/courier";
export async function POST(req: Request) {
const secret = req.headers.get("x-pathao-signature");
if (!verifyPathaoWebhook({ headerSecret: secret, expectedSecret: process.env.PATHAO_WEBHOOK_SECRET! })) {
return new Response("unauthorized", { status: 401 });
}
const evt = parsePathaoWebhook(await req.text());
// evt.status is canonical: "picked_up" | "in_transit" | "delivered" | "returned" | ...
// look up evt.merchantOrderId and transition your @lacspace/order accordingly
return new Response("ok", { status: 200 });
}React
Tiny, SSR-safe and dependency-free — global state, data fetching, theming and 28 essential hooks.
Global state in ~1KB
A Zustand-shaped store with no provider. Select a slice; only components using it re-render.
import { create } from "@lacspace/store";
const useCounter = create<{ count: number; inc: () => void }>((set) => ({
count: 0,
inc: () => set((s) => ({ count: s.count + 1 })),
}));
function Counter() {
const count = useCounter((s) => s.count);
const inc = useCounter((s) => s.inc);
return <button onClick={inc}>{count}</button>;
}Data fetching with a shared cache
SWR-shaped: components using the same key share the cache, de-dupe requests and revalidate together.
import { useQuery } from "@lacspace/query";
function Profile() {
const { data, error, isLoading } = useQuery(
"/api/me",
(url) => fetch(url as string).then((r) => r.json()),
);
if (isLoading) return <p>Loading…</p>;
if (error) return <p>Something went wrong.</p>;
return <h1>Hi, {data.name}</h1>;
}Theming with no flash
A next-themes-lite: a tiny provider, a useTheme hook, and a no-flash inline script.
import { ThemeProvider } from "@lacspace/theme";
export default function App({ children }: { children: React.ReactNode }) {
return <ThemeProvider defaultTheme="system" enableSystem>{children}</ThemeProvider>;
}Essential hooks & UI
@lacspace/hooks — useDebounce, useLocalStorage, useCopyToClipboard, useIntersectionObserver and 24 more. @lacspace/ui — scroll reveals, counters and a ⌘K palette.
Components & UI
143 React components across four packages — the interface layer, with no runtime dependencies and no Tailwind. Everything below is rendered live on the components gallery.
Install the UI kit
Four separate packages so you only pay for what you use. They share one set of CSS variables, so they match out of the box.
npm i @lacspace/components @lacspace/charts @lacspace/table @lacspace/dateMake it your brand in one block
Every colour, radius, control height and typeface is a --lac-* variable. Redefine the handful you care about and all 143 components follow — including the ones inside dialogs.
:root {
--lac-accent: #7c3aed;
--lac-accent-hover: #6d31d0;
--lac-radius: 14px;
--lac-font: "Inter", system-ui, sans-serif;
--lac-control-h-md: 42px;
}
/* Scope them to restyle one region instead of the whole app. */
.marketing-section {
--lac-accent: #0f766e;
--lac-radius-pill: 6px;
}A form that says what went wrong
Field wires up the label, the hint, the error and aria-describedby for you — the part hand-rolled forms almost always get wrong. Pair it with @lacspace/validate and the same schema guards your API route.
import { useState } from "react";
import { Button, Field, Input, PasswordInput, Stack } from "@lacspace/components";
import { v } from "@lacspace/validate";
const Schema = v.object({
email: v.string().email(),
password: v.string().min(12),
});
export function SignUp() {
const [errors, setErrors] = useState<Record<string, string>>({});
function onSubmit(form: FormData) {
const result = Schema.safeParse(Object.fromEntries(form));
if (!result.success) return setErrors(result.error.flatten()); // { email: "…" }
setErrors({});
// …post result.data, already typed
}
return (
<form action={onSubmit}>
<Stack gap={3}>
<Field label="Work email" hint="We never share it." error={errors.email}>
{({ id, describedBy, invalid }) => (
<Input id={id} name="email" type="email" aria-describedby={describedBy} invalid={invalid} />
)}
</Field>
<Field label="Password" hint="At least 12 characters." error={errors.password}>
{({ id, describedBy, invalid }) => (
<PasswordInput id={id} name="password" aria-describedby={describedBy} invalid={invalid} />
)}
</Field>
<Button type="submit" full>Create account</Button>
</Stack>
</form>
);
}A table people can actually use
Sorting, filtering, search, pagination, selection, column resize and pinning, sticky headers, footer totals and CSV export — declared, not wired by hand.
import { DataTable, textColumn, badgeColumn, currencyColumn, dateColumn } from "@lacspace/table";
<DataTable<Account>
caption="Account book"
data={accounts}
getRowId={(row) => row.id}
columns={[
textColumn<Account>("account", { header: "Account", key: "account", pinned: "left", width: 200 }),
badgeColumn<Account>("status", {
header: "Status",
key: "status",
tones: { active: "success", trial: "info", past_due: "warning", churned: "danger" },
}),
currencyColumn<Account>("mrr", { header: "MRR", key: "mrr", currency: "USD", aggregate: "sum" }),
dateColumn<Account>("renews", { header: "Renews", key: "renews" }),
]}
defaultSort={[{ id: "mrr", direction: "desc" }]}
searchable
selectable
exportable
columnMenu
stickyHeader
/>Keep your own markup
useTable() is the same engine with no UI attached. Use it when the design calls for cards, a list or a mobile layout instead of a grid.
import { useTable, textColumn, numberColumn } from "@lacspace/table";
const table = useTable<Agent>({
data: agents,
columns: [
textColumn<Agent>("agent", { key: "agent" }),
numberColumn<Agent>("resolved", { key: "resolved" }),
],
getRowId: (row) => row.id,
defaultSort: [{ id: "resolved", direction: "desc" }],
defaultPageSize: 5,
});
<input value={table.query} onChange={(e) => table.setQuery(e.target.value)} />
<ul>
{table.rows.map((model) => (
<li key={model.id}>{model.row.agent} — {model.row.resolved}</li>
))}
</ul>
<button disabled={!table.canNextPage} onClick={table.nextPage}>
Next — {table.range.from}–{table.range.to} of {table.range.total}
</button>A dashboard row: metric, trend, chart
Stat handles the arrow, the sign and the colour — including metrics where down is good. The charts are plain SVG: no canvas, no D3, and they take the same theme tokens.
import { Stat, Grid } from "@lacspace/components";
import { LineChart } from "@lacspace/charts";
<Grid columns={3} gap={3}>
<Stat label="MRR" value="$132,215" delta={8.4} comparison="vs last 30 days" />
<Stat label="Churn" value="1.9%" delta={-0.4} invertDelta comparison="vs last 30 days" />
<Stat label="Open tickets" value="24" delta={12} deltaUnit="absolute" invertDelta />
</Grid>
<LineChart
labels={months}
series={[
{ name: "MRR", data: mrr },
{ name: "Plan", data: plan, dashed: true },
]}
curve
dots
tooltip
responsive
dataTable
formatValue={(n) => "$" + n + "k"}
/>Filter by a date range
Presets, two months side by side, a minimum stay, disabled days and real keyboard navigation. Formatting goes through Intl, so no locale data ships in your bundle.
import { DateRangePicker, defaultPresets } from "@lacspace/date";
<DateRangePicker
numberOfMonths={2}
weekStartsOn={1}
minNights={1}
presets={defaultPresets()}
separator=" – "
onChange={(range) => setRange(range)}
/>Confirm before you delete, and say what happened
Return a promise from onConfirm and the button spins, blocks further clicks and keeps the dialog open if the request fails — so a failed delete never looks like a successful one.
import { ConfirmDialog, Button, useToast } from "@lacspace/components";
function DeleteProject({ project }: { project: Project }) {
const [open, setOpen] = useState(false);
const { toast } = useToast();
return (
<>
<Button tone="danger" variant="soft" onClick={() => setOpen(true)}>Delete</Button>
<ConfirmDialog
open={open}
onOpenChange={setOpen}
tone="danger"
title="Delete this project?"
message={`"${project.name}" and its history will be removed. This cannot be undone.`}
confirmLabel="Delete project"
onConfirm={async () => {
await api.delete(project.id);
toast({ title: "Project deleted", tone: "success" });
}}
/>
</>
);
}It server-renders as it is
Nothing touches window, document or matchMedia during render, and ids come from a stable generator, so server and client markup match. In the App Router you import the stylesheets once and use the components anywhere.
import "@lacspace/components/styles.css";
import "@lacspace/charts/styles.css";
import "@lacspace/table/styles.css";
import "@lacspace/date/styles.css";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}Four ways to change a component
All four work on every component. If one of them doesn't, treat it as a bug in the library, not in your code.
| Change how it looks everywhere | Redefine the --lac-* variables |
| Change one instance | Pass className or style — yours lands last and wins, no !important |
| Change its structure | Use the parts (CardHeader, CardBody, CardFooter) instead of one big prop |
| Drive it from outside | Pass value and onChange — everything stateful is controllable |
Resilience & speed
Flaky third-party calls are a fact of life. Back off, cache, and de-dupe.
Retry with backoff + cache
retry adds exponential backoff with jitter and a circuit breaker; cache is an LRU + TTL with stale-while-revalidate.
import { retry } from "@lacspace/retry";
import { createCache } from "@lacspace/cache";
const cache = createCache<Rate>({ max: 500, ttl: 60_000 });
async function getRate(pair: string) {
const hit = cache.get(pair);
if (hit) return hit;
const rate = await retry(() => fetch(`/fx/${pair}`).then((r) => r.json()), {
retries: 4,
minDelay: 300,
shouldRetry: (err) => isTransient(err), // don't retry 4xx
});
cache.set(pair, rate);
return rate;
}Scaffold a whole app
Don't start from an empty page. create-lacspace-app writes a finished Next.js app in ~0.12s — every page filled, SEO wired, a 26-component UI kit included.
One command, a finished app
Pick a template, recolour it with --theme, and grow it later with add.
# scaffold from any of 9 templates, recoloured to your brand
npm create lacspace-app@latest my-app -- --template saas --theme lacspace
# grow an existing app — drop in prebuilt, themed sections
npx create-lacspace-app add pricing faq testimonials
cd my-app && npm run devIntegrations
Because the packages are isomorphic, they slot into whatever you're already using.
Where each package fits
| Next.js (App Router) | defineSite() for metadata + JSON-LD, @lacspace/headers in next.config, sitemap.ts / robots.ts route files, @lacspace/og for OG images. |
| Node backends | jwt, password, mailer, rate-limit, webhooks, pdf, xlsx — no native addons, no headless browser. |
| Edge runtimes | Web-Crypto packages (crypto, signed-url, otp, jwt) run wherever SubtleCrypto exists. |
| React apps | store, query, theme, hooks, ui — SSR-safe, ship "use client" where needed. |
Upgrading
Packages follow semver. Minor and patch releases are safe anytime; breaking changes land only in majors.
Keep current
npm outdated # what's behind
npm update # upgrade within your ranges
npx npm-check-updates -f "@lacspace/*" -u && npm install # jump to newest majorsFAQ
Frequently asked
Are the @lacspace packages really zero-dependency?
Yes. Verify any of them with npm view @lacspace/crypto dependencies. The only dependencies you'll ever see are other @lacspace packages.
Do I have to use the whole ecosystem?
No — each package stands alone. Install one, or scaffold a whole app with create-lacspace-app; both are first-class.
Can I use these on the edge / in the browser?
Most packages are isomorphic and run in Node, the browser and edge runtimes. Anything cryptographic uses Web Crypto, which the edge provides. A few (mailer, pdf, xlsx) are server-side by nature.
What does it cost?
Everything is free under the Lacspace Free Licence — a permissive, free-to-use licence. Use it in personal and commercial projects at no cost.
Packages in this guide
Every package referenced above — jump straight to its README on npm.