Developer Handbook

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.

terminal
npm i @lacspace/validate @lacspace/money @lacspace/jwt

First calls

Import named functions and go. Nothing is global; tree-shaking keeps your bundle tiny.

quickstart.ts
import { slugify } from "@lacspace/slugify";
import { money } from "@lacspace/money";

slugify("Hello, World! — 2026");   // "hello-world-2026"
money(19.99, "USD").format();       // "$19.99"
Try it in the playground →

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.

Isomorphic by design: the same build runs in Node, the browser and edge runtimes.

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 dependencies returns {}.
  • 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; import and require both work, each with .d.ts types.
  • 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().

register.ts
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.

login.ts
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.

session.ts
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.

app/api/login/route.ts
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.

twofa.ts
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.

apikeys.ts
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.

crypto.ts
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

lib/seo.ts
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.

app/pricing/page.tsx
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.

app/sitemap.ts · robots.ts · og/route.tsx
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.

env.ts
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.

mail.ts
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.

app/api/webhook/route.ts
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.

reset-link.ts
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.

invoice.ts
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.

schema.ts
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 });
Try it in the playground →

Handle money without float bugs

Integer minor units, so a cent never disappears. allocate() splits a total so the remainder is distributed, not lost.

money.ts
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 mismatch
Try it in the playground →

Export to Excel & parse CSV

Write real .xlsx with no headless browser; parse RFC-4180 CSV correctly.

export.ts
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.

checkout.ts
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 paisa
Tax is per-line via @lacspace/tax rates (RATES.NP_VAT = 0.13); @lacspace/coupon plugs in the same way for discounts.
Try it in the playground →

Track 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.

lifecycle.ts
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.

returns.ts
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.

payouts.ts
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 0

Issue 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.

invoice.ts
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/xlsx

Take an eSewa payment

Build the signed redirect form, then verify the signature AND the amount against your own order — never trust the redirect alone.

esewa.ts
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
}
eSewa ePay v2 signs with HMAC-SHA256 over Web Crypto — the same code runs on Node, edge and the browser.

Take a Khalti payment

Initiate a payment (amount in paisa), redirect, then confirm server-to-server with a lookup before you fulfil.

khalti.ts
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.

bank-rails.ts
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.

app/api/pathao/route.ts
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.

store.ts
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.

Profile.tsx
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.

app/layout.tsx
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.

terminal
npm i @lacspace/components @lacspace/charts @lacspace/table @lacspace/date
Import each stylesheet once, at the top of your root layout. React 18+ is the only peer dependency.

Make 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.

globals.css
: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;
}
Dark mode is already defined for both the OS preference and an explicit data-theme attribute, so a theme toggle wins in both directions.

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.

SignUp.tsx
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>
  );
}
PasswordInput ships a strength meter driven by scorePassword(), which is exported on its own if you want the number without the input.

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.

Accounts.tsx
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
/>
The CSV export neutralises leading =, +, - and @ in cells, so an exported row can never execute as a formula when somebody opens it in Excel.

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.

Leaderboard.tsx
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.

Overview.tsx
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"}
/>
dataTable renders the same numbers as a visually-hidden table, so the chart is readable by a screen reader and by search engines — not just by eyes.

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.

RangeFilter.tsx
import { DateRangePicker, defaultPresets } from "@lacspace/date";

<DateRangePicker
  numberOfMonths={2}
  weekStartsOn={1}
  minNights={1}
  presets={defaultPresets()}
  separator=" – "
  onChange={(range) => setRange(range)}
/>
The date engine underneath (monthGrid, addMonths with end-of-month clamping, isDateDisabled) is exported separately and tested across DST boundaries, so you can use it server-side with no React.

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.

DeleteProject.tsx
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" });
        }}
      />
    </>
  );
}
Wrap your app in <ToastProvider> once. maxVisible is a queue rather than a cap — extra toasts wait for a slot, and hovering the stack pauses every countdown.

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.

app/layout.tsx
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>
  );
}
No CSS pipeline at all (a CDN page, a widget inside somebody else's app)? Render <LacspaceStyles /> instead — it injects the sheet once and is a no-op if it is already there.

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 everywhereRedefine the --lac-* variables
Change one instancePass className or style — yours lands last and wins, no !important
Change its structureUse the parts (CardHeader, CardBody, CardFooter) instead of one big prop
Drive it from outsidePass value and onChange — everything stateful is controllable
Variants are exposed as data-* attributes rather than class names, so your CSS can target any state: .lac-btn[data-variant="solid"][data-loading] { opacity: 0.8; }

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.

resilient.ts
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.

terminal
# 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 dev

Integrations

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 backendsjwt, password, mailer, rate-limit, webhooks, pdf, xlsx — no native addons, no headless browser.
Edge runtimesWeb-Crypto packages (crypto, signed-url, otp, jwt) run wherever SubtleCrypto exists.
React appsstore, 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

terminal
npm outdated                                   # what's behind
npm update                                     # upgrade within your ranges
npx npm-check-updates -f "@lacspace/*" -u && npm install   # jump to newest majors
Watch for EBADENGINE: the crypto-using packages moved their engines floor to Node 20. Bump your runtime to Node 20+ — the only environment change the ecosystem has ever required.

FAQ

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.