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 63 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);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.
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.15s — 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 8 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.