New:Introducing Socket Scanning for VS Code Marketplace Extensions.Learn more →
Get Started

@mapled/next

Package Overview
Dependencies
Maintainers
1
Versions
22
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@mapled/next

Read published Mapled content in a Next.js site — delivery client, ISR tags, and a revalidation webhook handler.

npmnpm
Version
0.13.0
Version published
Weekly downloads
196
-37.38%
Maintainers
1
Weekly downloads
 
Created
Source

@mapled/next

Read published Mapled content in a Next.js site — a delivery client with ISR cache tags, a revalidation webhook handler so publishes show up instantly, and live updates for the tabs that are already open.

Mapled is a hosted headless CMS built for sites created with AI. Your agent (or you) models collections and fills in content; this package is how the site reads what's published.

Install

npm install @mapled/next

Grab the delivery key in Mapled: project settings (the gear in the project's header) → Integrations → Your site, and put it in your site's env:

MAPLED_KEY=mk_live_…

Integrate by hand

An AI agent connected through @mapled/mcp does all of this for you. Nothing in it needs one, though: this is the same integration, step by step — from a Next.js site with hardcoded content to a site its editors run from Mapled. Plan on an hour for a small site.

You need a Mapled project where you can change the structure (the builder plan), a Next.js 14+ site on the App Router, and Node 18 or newer. The example is a site with a homepage and a blog; the keys (homepage, articles) are whatever you choose in step 1.

1. Model the content

In Mapled, open the project and press the wrench in the header (Structure) → Collections. Create a single for every one-off part of the site (homepage, footer) and a collection for every list of alike things (articles, team), then Add field for each text, image and link the code hardcodes today.

  • Keys are what the code reads — getRecords("articles"), data.title. A key can be renamed later and the old one keeps working as an alias, but it is a migration: choose them with care.
  • A collection whose records have pages of their own needs a Slug field — getRecordBySlug finds records by it.
  • Write a Help text for every field. Editors see it under the field; write it for someone who has never seen the code.
  • Mark a field Sensitive when editors keep it but the site must never receive it.

2. Fill it in and publish once

Open Content, move the hardcoded texts and images into records, then Review & publish. The site only ever reads published releases: before the first publish the delivery API answers 404 “Nothing published yet.” — getSingle and getRecord read that as null, getRecords throws.

npx @mapled/cli auth login
npx @mapled/cli project link
npx @mapled/cli types generate
npx @mapled/cli schema pull

Your browser opens Mapled, you pick the project and authorize; project link writes mapled.json, types generate writes mapled-types.ts — an interface per collection and single, tied together by MapledSchema — and schema pull records the schema the site is built against in mapled/schema.json, which schema diff and doctor compare with Mapled later. (@mapled/cli has the details of every command used here.)

4. Create the client

Install the package and set MAPLED_KEY (Install) in .env.local and in your hosting's environment. Then one client for the whole site:

// lib/mapled.ts
import { createClient } from "@mapled/next";
import type { MapledSchema } from "../mapled-types";

export const mapled = createClient<MapledSchema>({ key: process.env.MAPLED_KEY! });

With MapledSchema every read is typed by its key, and a misspelled collection or field is a build error.

5. Replace the hardcoded content with reads

Read in Server Components — no client-side fetching, no loading states. A single:

// app/page.tsx
import { assetUrl } from "@mapled/next";
import { mapled } from "../lib/mapled";

export default async function Home() {
  const home = await mapled.getSingle("homepage");
  if (!home) return null; // nothing published yet

  return (
    <main>
      <h1>{home.data.headline}</h1>
      <p>{home.data.intro}</p>
      {home.data.cover ? <img src={assetUrl(home.data.cover, { width: 1280 })} alt="" /> : null}
    </main>
  );
}

A list:

// app/blog/page.tsx
import Link from "next/link";
import { mapled } from "../../lib/mapled";

export default async function Blog() {
  const { records } = await mapled.getRecords("articles", { sort: "-date", limit: 20 });

  return (
    <ul>
      {records.map((post) => (
        <li key={post.id}>
          <Link href={`/blog/${post.data.slug}`}>{post.data.title}</Link>
        </li>
      ))}
    </ul>
  );
}

A page per record — new records get their page without a deploy:

// app/blog/[slug]/page.tsx
import { notFound } from "next/navigation";
import Markdown from "react-markdown";
import { mapled } from "../../../lib/mapled";

export async function generateStaticParams() {
  const { records } = await mapled.getRecords("articles", { fields: ["slug"] });
  return records.map((post) => ({ slug: post.data.slug }));
}

export default async function Article({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  const post = await mapled.getRecordBySlug("articles", slug);
  if (!post) notFound();

  return (
    <article>
      <h1>{post.data.title}</h1>
      <Markdown>{post.data.body}</Markdown>
    </article>
  );
}

(params is a Promise since Next.js 15; on 14 it is the plain object.) Rich text is Markdown — render it with a component such as react-markdown, never as raw HTML. Leave in the code what editors shouldn't touch: layout, navigation structure, anything that is logic rather than content.

6. Refresh the site on publish

Reads are cached (revalidate: 3600) and tagged; a webhook refreshes them the moment someone publishes.

// app/api/mapled/revalidate/route.ts
import { createRevalidateHandler } from "@mapled/next/server";

export const POST = createRevalidateHandler({
  secret: process.env.MAPLED_WEBHOOK_SECRET!,
});

Deploy, then in Mapled → Integrations → Your site save https://your-site.com/api/mapled/revalidate as the Revalidation webhook, copy the Signing secret into MAPLED_WEBHOOK_SECRET (hosting env and .env.local), redeploy, and press Send test ping: the delivery shows up under Recent deliveries as Delivered. The URL has to be public — Mapled refuses addresses that resolve to a private network.

Optional: pages that are already open can follow a publish too, without a reload — a route and one component, see Live updates.

7. Preview drafts

// app/api/mapled/preview/route.ts
import { createPreviewHandler } from "@mapled/next/server";

export const GET = createPreviewHandler();
// app/api/mapled/preview/exit/route.ts
import { createExitPreviewHandler } from "@mapled/next/server";

export const GET = createExitPreviewHandler();

The Preview button in Mapled opens the site at the origin of the webhook URL from step 6, on exactly this path — /api/mapled/preview — so keep the route there. It turns on Next.js draft mode, and every read of the client switches to live drafts, uncached, with no changes in your components.

8. Forms

A contact or signup form posts straight to Mapled; submissions land in Forms, with e-mail notifications and spam protection. Create the form once:

npx @mapled/cli forms create "Contact"

and post to it from the site — see Forms for the component.

9. Live data

Orders, bookings, votes — records the site's backend writes and reads immediately, without Publish — go to collections created with the mode Live data and are reached with the server key: see Live data.

10. Record the integration

npx @mapled/cli scan --write
npx @mapled/cli bindings push
npx @mapled/cli md pull

scan reads the code and writes mapled/manifest.json: which page reads which collection and field. bindings push sends it to Mapled, so editors see where a field appears and get a warning before a change would break the site (Structure → Bindings). md pull writes MAPLED.md, the guide for whoever works on the site next — person or AI agent. Commit mapled.json, mapled-types.ts, mapled/schema.json, mapled/manifest.json and MAPLED.md; never commit an env file.

11. Check everything

npx @mapled/cli doctor

It checks the integration from both sides — the repository and what Mapled sees of the deployed site. The list below is what an agent's setup has to pass before Mapled calls it complete; doctor answers all of it except the last two lines, which are yours to check:

  • the site reads content from Mapled (“Site reads content — Last read …”);
  • a publish refreshes it (“Publish webhook — Delivered …”);
  • Preview in Mapled opens the site with drafts (“Preview on the site — Responds at …”);
  • bindings are pushed and healthy, and the integration is in sync (“Bindings”, “Integration”);
  • no env file or secret is tracked by git (“Secrets in git”);
  • every field has a help text (Structure → Collections);
  • the site builds.

Later, when the structure changes in Mapled, npx @mapled/cli schema diff says what changed and whether it breaks the site; after updating the code, run types generate, scan --write, bindings push and md pull again.

Read published content

import { createClient } from "@mapled/next";

const mapled = createClient({ key: process.env.MAPLED_KEY! });

// A collection, with filters, sort and pagination
const { records } = await mapled.getRecords("articles", {
  filter: { featured: true, date: { gte: "2026-01-01" }, title: { contains: "launch" } },
  sort: "-date",
  limit: 10,
});

// One record — by id or by its slug field
const post = await mapled.getRecord("articles", id);
const about = await mapled.getRecordBySlug("pages", "about");

// A single (e.g. the homepage)
const home = await mapled.getSingle("homepage");

// Linked records come along under `expanded` (two levels at most)
const { records: posts } = await mapled.getRecords("articles", {
  expand: ["author", "related.author"],
  fields: ["title", "slug"],
});
posts[0].expanded?.author; // { id, data: { name: "Ada" } } or null

Every call reads the latest published release — drafts never leak. Fetches are tagged mapled and mapled:<collection> and default to revalidate: 3600; the webhook below refreshes them the moment someone publishes.

Typed by your schema

npx @mapled/cli types generate writes mapled-types.ts: one interface per collection and single, and a MapledSchema that ties them together. Hand it to the client once and every read is typed by its key:

import type { MapledSchema } from "./mapled-types";

const mapled = createClient<MapledSchema>({ key: process.env.MAPLED_KEY! });

const { records } = await mapled.getRecords("articles"); // records[0].data is an Article
const home = await mapled.getSingle("homepage");          // Homepage | null
await mapled.getRecords("artcles");                       // type error — no such collection

Without a schema the client behaves as before: any key, Record<string, unknown> data, or the type you name per call (getRecords<Article>("articles")). createAppClient<MapledSchema> types live data the same way — db.create("orders", data) wants an Order. mapled doctor tells you when the generated file is behind the schema.

Renamed fields

When a field's key is renamed in Mapled, nothing breaks: the old key stays as an alias — delivered next to the new one, accepted in filter, sort, fields, expand and in live-data writes — until someone removes it in Field settings. Answers that still serve a previous key name it (aliases: { products: { price: "amount" } } on a list result), and outside production the client says so once per key:

[mapled] “price” in “products” was renamed to “amount”. The old key keeps working until its alias is removed in Mapled — use “amount” instead.

Move the code to the new key, run mapled types generate (the old key is typed @deprecated until then), and remove the alias. In production the client stays silent.

Pin a release

A page that makes several reads can pin them all to one release, so a publish landing mid-render can't mix two versions:

const live = await mapled.getRelease(); // { release: 12, publishedAt } — null before the first publish
const { records } = await mapled.getRecords("articles", { release: live!.release });
const about = await mapled.getRecordBySlug("pages", "about", { release: live!.release });

createClient({ key, release: 12 }) pins every read — a way to hold a site on a known-good release without publishing anything. A release never changes, so pinned reads are cached until the next publish (revalidate: false unless you pass your own); a release that doesn't exist fails with a 404 RELEASE_NOT_FOUND rather than reading as a missing record. In preview (draft mode) the pin is dropped: drafts are unversioned.

Read in a language

A project with several languages (Mapled: Settings → Languages) translates the fields an editor marked as translated. Every read takes locale:

const about = await mapled.getRecordBySlug("pages", "about", { locale: "ru" });
const { records, locale } = await mapled.getRecords("articles", { locale: "pt-BR", sort: "-date" }); // locale: "pt-BR"

A translated field answers in that language, or in the first language of the project's fallback chain that has a value — the default language at the end — so a value is never missing where the default language has one; shared fields come as they are. Filters, sorts and slug lookups see that language too, and the answer names the language it is in (locale). createClient({ key, locale: "ru" }) reads everything in that language (a read's own locale wins). Without locale a read answers in the project's default language, as before. A language the project doesn't have — or that the release you read was published without — fails with a 400 INVALID_QUERY.

getRecords takes search — full-text search of the published records of a collection with access Public:

import { mapled } from "@/lib/mapled";

export default async function SearchPage({ searchParams }: { searchParams: Promise<{ q?: string }> }) {
  const q = ((await searchParams).q ?? "").trim().slice(0, 200);
  const found = q ? await mapled.getRecords("articles", { search: q, limit: 20, revalidate: 60 }) : null;
  return (
    <main>
      <form>
        <input name="q" defaultValue={q} placeholder="Search" />
      </form>
      {found?.records.map((r) => (
        <a key={r.id} href={`/articles/${r.data.slug}`}>
          {String(r.data.title)}
        </a>
      ))}
      {found && found.total === 0 ? <p>Nothing found.</p> : null}
    </main>
  );
}

Every word of the query has to be in the record; the last one may be the start of a word, so results follow the typing. The best match comes first — a match in the record's title weighs more — unless you pass sort; filter, limit and offset work as on any list, and total counts every match. Text and choice fields are searched, a sensitive field never. With locale the search reads that language: its translations (through the fallback chain) and its word forms — «running» finds «run» in English, «книги» finds «книга» in Russian. The query is 1–200 characters (longer is a 400 INVALID_QUERY), operators in it are plain text, and the answer names it as search.query. Search reads what is published; in preview it reads the drafts.

Rich text

rich_text values are Markdown (headings, lists, links, bold/italic, images as ![alt](url)). Render them with a Markdown component such as react-markdown — never inject them as raw HTML.

Images

Image fields hold asset ids. assetUrl turns one into a URL — the original, or a resized variant that Mapled makes on the first request and keeps:

import { assetUrl } from "@mapled/next";

<img src={assetUrl(post.data.cover, { width: 640 })} alt={post.data.title} />
// width/height snap to presets (64 … 2560) and never upscale; fit: inside | cover | contain;
// format: webp (default) | avif | jpeg | png; quality 30 … 100

Refresh on publish

Mount the webhook handler:

// app/api/mapled/revalidate/route.ts
import { createRevalidateHandler } from "@mapled/next/server";

export const POST = createRevalidateHandler({
  secret: process.env.MAPLED_WEBHOOK_SECRET!,
});

Then in Mapled (Integrations → Your site) set the revalidation webhook to https://your-site.com/api/mapled/revalidate and copy the signing secret into MAPLED_WEBHOOK_SECRET. Every publish sends a signed ping; the handler verifies the x-mapled-signature HMAC and revalidates the mapled tags.

Each ping is tried up to three times (after 2 and 6 seconds, 5 seconds to answer); Recent deliveries keeps them for 30 days, and a failed one can be sent again from there. Rotate secret replaces the signing secret at once — deliveries fail until the site has the new one.

Live updates

The webhook refreshes the site's cache; a page someone already has open still shows what it rendered. Two files make open tabs follow Publish as well. A route that says which release the site serves:

// app/api/mapled/release/route.ts
import { createReleaseHandler } from "@mapled/next/server";

export const GET = createReleaseHandler({ key: process.env.MAPLED_KEY! });

and one component in the root layout:

// app/layout.tsx
import type { ReactNode } from "react";
import { MapledLive } from "@mapled/next/live";

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="en">
      <body>
        {children}
        <MapledLive />
      </body>
    </html>
  );
}

<MapledLive /> renders nothing. While the tab is visible it asks the route every 10 seconds (interval, in seconds), and when a publish lands it calls router.refresh(): Server Components re-render with the new release in place — scroll position and client state stay. A hidden tab doesn't ask; it checks the moment it is shown again. The first check counts too: when the release it finds was acknowledged by your site's cache after the page was requested — a publish between the render and that check, or while a tab opened in the background waited to be shown — the page refreshes, and onPublish gets previous as null (which release the page rendered, the tab can't tell). A page requested within ten seconds after the site answered a publish's webhook may refresh once for nothing. To offer the update instead of applying it under the reader, pass onPublish:

"use client";

import { useRouter } from "next/navigation";
import { useState } from "react";
import { MapledLive } from "@mapled/next/live";

export function UpdateNotice() {
  const router = useRouter();
  const [fresh, setFresh] = useState(false);
  return (
    <>
      <MapledLive onPublish={() => setFresh(true)} />
      {fresh ? <button onClick={() => { router.refresh(); setFresh(false); }}>New content — refresh</button> : null}
    </>
  );
}
  • It is polling, and it is cheap: tabs ask your route, never Mapled. The route reuses one answer for ttl seconds (default 5) — in the instance and, through s-maxage, at the CDN in front of it — and a check that finds nothing new is a 304. However many tabs are open, Mapled hears from the site a few times a minute, and the key's rate limit is left for reads.
  • The route moves when the webhook has refreshed your site's cache, and not before — a tab that refreshes on it never renders the old release again. It reads the release under the same mapled cache tag as the content, and never names one newer than the release your site's cache last answered the publish webhook for: the webhook handler revalidates the cache before it answers, its answer carries the cache's mark (x-mapled-cache), and Mapled records the answer with the mark. So a cache entry filled early — after a deploy, an eviction, or when its revalidate window runs out between a publish and its webhook — doesn't move the route ahead of the pages. Only while a publish's webhook is on its way — or failing — does the route ask Mapled past its cache, once per ttl. Live updates therefore need the webhook from Refresh on publish: until your site has answered a publish's webhook, the route names the release before it (null before the first one it answered), and without a webhook it names nothing new — nothing tells it when your pages have a release, so open tabs don't follow Publish. A new webhook address starts over: the site there hasn't answered anything yet.
  • Live updates follow the deployment the webhook reaches. Another deployment on the same key — a preview, a second site — renders from a cache of its own, which no publish revalidates, so its route names no release and its open tabs stay as they are. The route tells the answers apart by the mark: each Next.js cache keeps its own (cacheMark()), so deployments that share a cache share the mark, and one with a cache of its own has another. After a deploy onto a fresh cache — or once the cache has lost the mark — the route names no release until the next publish's webhook has been answered from that cache. (Before 0.11.0 such a deployment followed the webhook's one and could refresh its tabs into the release they already showed.)
  • A webhook handler of your own — one that refreshes more than the mapled tag, say — answers with the cache's mark, or the route names no release (it logs once why):
import { revalidateTag } from "next/cache";
import { cacheMark, verifySignature } from "@mapled/next/server";

export async function POST(req: Request) {
  const body = await req.text();
  const signature = req.headers.get("x-mapled-signature") ?? "";
  if (!(await verifySignature(process.env.MAPLED_WEBHOOK_SECRET!, body, signature))) {
    return Response.json({ error: "Invalid signature." }, { status: 401 });
  }
  revalidateTag("mapled");
  // after revalidating: the mark says which cache has let go of the old release
  return Response.json({ revalidated: true }, { headers: { "x-mapled-cache": await cacheMark() } });
}
  • The route answers { release, publishedAt, acknowledgedAt } — the number of publishes, the time of the last one and when your site's cache answered its webhook (0.11.3 and later; the Pages Router's route answers the first two), nothing else — and may be cached by anyone. While Mapled can't be reached it keeps answering the last release it knew.
  • Refreshes ask your site, not the browser's cache. router.refresh() asks for the page's Server Components at the same URL every time, and a static page answers with s-maxage and stale-while-revalidate but no max-age: stale for the browser at once, which Chrome and Edge serve anyway while they fetch a fresh copy behind it. So from the first publish a tab hears about, every refresh of the page goes with cache: "no-cache", and your site may still answer 304. That covers the component's own refresh, the one your onPublish makes (like the button above), and any your code makes. The layer changes only the cache mode, and only of requests your server reads as a refresh of the page the tab is on. Navigations, server actions, your own requests and any request that sets its own cache go as they did. A layer over fetch, put in place with the first publish, does it; a fetch wrapper of your own keeps working either way. (Before 0.11.1 an open tab showed the release before the one it was told about, from its second publish on.)
  • A refresh that lands on the old page is made again. On Vercel, revalidateTag blocks the first request after the webhook and answers it fresh (x-vercel-cache: REVALIDATED); the requests that arrive while that one regenerates, and for about half a second after, get the old page, marked x-vercel-cache: STALE — a second tab on the same page, or a tab and a visitor, whose refreshes fall into one regeneration. next start answers the first request fresh as well. A webhook handler of your own that calls revalidateTag("mapled", "max") (Next 16) answers everyone the old page until the regeneration lands, about a second, marked x-nextjs-cache: STALE. In each case the tab would show the release before the one it was told about. So a refresh answered STALE on either header within 30 seconds of a publish is repeated after 1, 2 and 4 seconds — at most three times per publish, through the mounted <MapledLive />, whether the publish refreshed the page itself or your onPublish did; a fresh answer ends it. Prefetches, navigations and a STALE answer outside that window (a page on timed revalidate is answered so by design) are left alone. (0.11.4 and later.)
  • Expect a publish to reach an open tab in about ten seconds with the defaults. <MapledLive interval={3} /> and createReleaseHandler({ key, ttl: 1 }) make it quicker at the price of more requests to your site.
  • With a basePath in next.config, the route answers under it, and so does the default: <MapledLive /> asks /docs/api/mapled/release on a site with basePath: "/docs" — Next writes the basePath into the bundle, and the component reads it from there (0.10.0 and later; earlier versions ask /api/mapled/release). An endpoint you pass is asked as written, basePath included: <MapledLive endpoint="/docs/live" />.
  • Leave route segment config off the release route: createReleaseHandler sets how it is cached. dynamic = "force-static" turns it into a copy Next builds without the tab's request, and fetchCache = "force-no-store" reads the release past the cache tag, so Mapled hears from the route every ttl. dynamic = "force-dynamic" does the same when the handler's revalidate is false or 0; at its default it is harmless. On the webhook route, leave fetchCache off too: with "force-no-store" every answer names a new cache mark, and the release route may name no release. Middleware that runs on the route has to let the tab's GET through: no redirect, rewrite or sign-in wall. output: "export" has no server to run the route at all.

Outside React, watchRelease({ onPublish }) from @mapled/next is the same watch as a plain function — it returns the function that stops it.

Pages Router

A site on pages/ mounts the route as an API route and renders the component from @mapled/next/live/pages in its custom App:

// Pages Router: pages/api/mapled/release.ts
import { createPagesReleaseHandler } from "@mapled/next/server";

export default createPagesReleaseHandler({ key: process.env.MAPLED_KEY! });
// Pages Router: pages/_app.tsx
import type { AppProps } from "next/app";
import { MapledLive } from "@mapled/next/live/pages";

export default function App({ Component, pageProps }: AppProps) {
  return (
    <>
      <Component {...pageProps} />
      <MapledLive />
    </>
  );
}

On a publish it replaces the page with itself through next/router, so the page's data runs again and it re-renders with the new props in place: scroll position and client state stay. A #hash in the address goes: while there is one, a replace to the same page runs no data, and every render the router makes scrolls back to the hash's anchor. For the router it is a navigation to the same URL, so routeChangeStart and routeChangeComplete fire — a pageview counter listening to them counts it too. A publish that lands while the reader is on the way to another page, or to an anchor on this one, doesn't cancel that navigation: it waits for the page they land on and refreshes that one. The first check counts too: when the release it finds went live after the page was requested — a publish between getServerSideProps and that check, or while a tab opened in the background waited to be shown — the page refreshes, and onPublish gets previous as null (which release the page read, the tab can't tell). A page requested within ten seconds after a publish may refresh once for nothing. endpoint, interval and onPublish work as above. (<MapledLive /> from @mapled/next/live would reload the whole tab here.)

  • Pages that follow Publish read in getServerSideProps. The Pages Router has no cache tag the route could share with a page, so createPagesReleaseHandler answers the live release as Mapled has it, and it moves the moment someone publishes. A page that reads in getServerSideProps reads that same release, so the refresh always renders the new content. Keep such a page out of shared caches — Next.js sends it uncached unless you set Cache-Control yourself.
  • A getStaticProps page can't keep up: it is a copy Next.js regenerates on its own schedule — when its revalidate window runs out, or when a webhook of yours calls res.revalidate — and the route can't wait for that. A tab that refreshes before the copy does gets the old copy back and won't hear of this publish again. Read the pages that should follow Publish in getServerSideProps instead.
  • Reads a page makes in the browser aren't part of its data: reload them in onPublish.

Preview drafts

Mount the preview routes:

// app/api/mapled/preview/route.ts
import { createPreviewHandler } from "@mapled/next/server";
export const GET = createPreviewHandler();
// app/api/mapled/preview/exit/route.ts
import { createExitPreviewHandler } from "@mapled/next/server";
export const GET = createExitPreviewHandler();

The Preview button in Mapled opens your site through a one-time link: the route turns on Next.js draft mode, and every getRecords/getRecord/getSingle call automatically switches to live drafts, uncached. No code changes in your components — the client detects draft mode on its own. Sessions last two hours; /api/mapled/preview/exit leaves preview.

Mapled finds the site by the revalidation webhook's URL — preview opens at its origin, on /api/mapled/preview — so set the webhook up first and keep the route on that path.

Forms

A form is created once — npx @mapled/cli forms create "Contact", or by your AI agent — and gets a key (contact). The site posts submissions as flat JSON with the delivery key; they show up in Mapled under Forms right away, and the form's recipients — the project's owners, unless its settings name others — get an e-mail. Forms never wait for Publish.

// app/contact/ContactForm.tsx
"use client";

import { useState, type FormEvent } from "react";

const CONSENT = "I agree to be contacted about my request.";

export function ContactForm() {
  const [state, setState] = useState<"idle" | "sending" | "sent" | "failed">("idle");

  async function submit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    setState("sending");
    const form = new FormData(event.currentTarget);
    const res = await fetch("https://api.mapled.io/v1/delivery/forms/contact", {
      method: "POST",
      headers: { "content-type": "application/json", "x-mapled-key": process.env.NEXT_PUBLIC_MAPLED_KEY! },
      body: JSON.stringify({
        name: form.get("name"),
        email: form.get("email"),
        message: form.get("message"),
        _gotcha: form.get("_gotcha"), // the honeypot: people leave it empty
        _consent: form.get("consent") ? CONSENT : undefined,
        _page: window.location.pathname,
      }),
    });
    setState(res.ok ? "sent" : "failed");
  }

  if (state === "sent") return <p>Thank you — we'll be in touch.</p>;
  return (
    <form onSubmit={submit}>
      <input name="name" required />
      <input name="email" type="email" required />
      <textarea name="message" required />
      <input name="_gotcha" tabIndex={-1} autoComplete="off" aria-hidden="true" style={{ display: "none" }} />
      <label>
        <input name="consent" type="checkbox" required /> {CONSENT}
      </label>
      <button disabled={state === "sending"}>Send</button>
      {state === "failed" ? <p>That didn't go through. Try again in a minute.</p> : null}
    </form>
  );
}
  • The endpoint is open to browsers (CORS) and takes the delivery key — the one key that is safe in client code. Expose it as NEXT_PUBLIC_MAPLED_KEY; never the server key or the webhook secret. To keep even that out of the bundle, post from a Server Action with MAPLED_KEY instead — all visitors then share your server's address for the limit below.
  • The body is one flat object: up to 50 fields of text (5,000 characters), numbers and booleans. Keys starting with _ are instructions, not answers: _gotcha (a hidden field bots fill in — such submissions are accepted and filed as spam), _consent (the exact label of the consent checkbox the visitor ticked, kept as evidence), _page (where the form was), _test: true (a test submission: no e-mail, not counted).
  • 201 { "ok": true } on success. 400 VALIDATION_FAILED for a body of another shape, 404 for an unknown key or form, 429 RATE_LIMITED past 30 submissions a minute per form and address, 429 FORMS_LIMIT when the project's monthly submissions are used up, 410 PROJECT_ARCHIVED for an archived project.
  • npx @mapled/cli forms list shows the project's forms and their URLs.

Live data

Operational collections skip the publish cycle — saves take effect immediately. Your site's backend reads and writes them through the Application Data API with the project's server key (Mapled: Integrations → Your site). Server-side only; the key must never reach the browser.

MAPLED_SERVER_KEY=msk_live_…
import { createAppClient } from "@mapled/next";

const db = createAppClient({ serverKey: process.env.MAPLED_SERVER_KEY! });

await db.create("orders", { customer: "Ada Lovelace", total: 120 });
const { records } = await db.list("orders", { filter: { status: "new" }, sort: "-total" });
await db.update("orders", id, { ...data, status: "shipped" });
await db.remove("orders", id);

The server key reaches only operational collections with access class public/server — editorial content, internal and sensitive collections stay out of reach, and it grants nothing on the Management API.

A write is checked against the collection's fields: data that doesn't fit them is a 400 VALIDATION_FAILED, a slug another record uses a 409 SLUG_TAKEN. A write that ran while a field it sends was renamed, removed or changed is a 409 FIELDS_CHANGED and writes nothing — send it again against the fields as they are now.

Private files

A file that only live data names — an applicant's CV, a scan on an order — is private: /files/{id} answers 404 without a signature. Every live-data answer carries a signed URL for each private file its records name, as record.files (one map per page of list), and assetUrl takes it:

// lib/mapled.ts
import { assetUrl, createAppClient } from "@mapled/next";

export const db = createAppClient({ serverKey: process.env.MAPLED_SERVER_KEY! });

/** The link to an application's CV. */
export async function cvUrl(applicationId: string): Promise<string | null> {
  const application = await db.get<{ name: string; cv: string }>("applications", applicationId);
  return application ? assetUrl(application.data.cv, { files: application.files }) : null;
}

The signed URL stops working at files[id].expiresAt — 15 minutes — so make it per request: never in a cached page, never at build time. It may go to the browser (an <a href>, an <img src>); the server key never does.

Site users

Your site can have accounts of its own — sign-up with email and password, a verification mail, sign-in, password reset — kept by Mapled per project. Turn Site Identity on in the project's Identity settings and name the pages of your site the mails link to (verify, reset); the forms live on your pages, Mapled has none. Your server talks to Mapled with the server key; the browser talks to your server. The session is a cookie on your domain, mapled_session — HttpOnly, Secure, SameSite=Lax — that only your server reads: the token in it never reaches the browser's JavaScript. The cookie lasts the 90 days a session may at most; within them Mapled decides whether the session is still live (it slides 30 days with each check), and a cookie whose session ended names no one.

One route takes the browser's requests — JSON POSTs to /api/mapled/identity/sign-up, verify, sign-in, session, sign-out, reset/request and reset — and sets or clears the cookie. serverKey left out is MAPLED_SERVER_KEY of the environment:

// app/api/mapled/identity/[...action]/route.ts
import { createIdentityHandler } from "@mapled/next/server";

export const POST = createIdentityHandler();

The answers are the API's — { user }, or { error: { code, message } } with its status — except that sign-in answers the user and sets the cookie, session answers the person the cookie names (401 SESSION_INVALID otherwise, with reason: "suspended" when an editor suspended them, the cookie cleared) and sign-out ends the session and clears it. Only a same-site JSON request is taken, so a form another site posts signs no one in or out.

Server components ask who is signed in — the cookie is checked with Mapled on every request, never cached, so a sign-out elsewhere, a suspension or a deletion is seen at once:

// app/account/page.tsx
import { getSiteUser } from "@mapled/next/server";

export default async function Account() {
  const user = await getSiteUser();
  if (!user) return <p>You are signed out.</p>;
  return <p>Signed in as {user.email}</p>;
}

Plain forms go through server actions — signIn sets the cookie, signOut clears it, signUp sends the verification mail (the person signs in once they have clicked its link); a refusal is a MapledError with the API's code (INVALID_CREDENTIALS, EMAIL_NOT_VERIFIED, USER_SUSPENDED):

// app/account/actions.ts
"use server";
import { redirect } from "next/navigation";
import { signIn, signOut, signUp } from "@mapled/next/server";

export async function signInAction(form: FormData) {
  await signIn({ email: String(form.get("email")), password: String(form.get("password")) });
  redirect("/account");
}

export async function signUpAction(form: FormData) {
  await signUp({ email: String(form.get("email")), password: String(form.get("password")), profile: { name: String(form.get("name")) } });
  redirect("/account/check-your-inbox");
}

export async function signOutAction() {
  await signOut();
  redirect("/");
}

The mails land on your pages with ?token=…. Spend the token on a click, not on the page load — a mail scanner that opens the link must spend nothing — through the identity client every createAppClient carries, db.identity: signUp, verify, signIn, session, signOut, requestReset, reset, each one call to the API, each refusal a MapledError with the API's status and code:

// app/account/verify/actions.ts
"use server";
import { redirect } from "next/navigation";
import { createAppClient } from "@mapled/next";

const db = createAppClient({ serverKey: process.env.MAPLED_SERVER_KEY! });

/** The verify page's button: the mail's link landed on the page, the click spends the token. */
export async function confirmEmail(form: FormData) {
  await db.identity.verify(String(form.get("token")));
  redirect("/account/sign-in?verified=1");
}

/** The reset page's form: the token from the mail, the new password. Every session of the person ends. */
export async function setPassword(form: FormData) {
  await db.identity.reset({ token: String(form.get("token")), password: String(form.get("password")) });
  redirect("/account/sign-in?reset=1");
}

Sign-up answers the same for a new and a taken address (the taken one gets a mail instead), sign-in one 401 for an unknown address and a wrong password; passwords are 10 to 128 characters. Editors see the project's users in Mapled — profile, roles, status — and may suspend, restore, sign out everywhere, send a reset or delete; never a password, never a token.

Data of signed-in people

A collection for signed-in users (access class authenticated-site-user, on Live data) belongs to the site's users: a record made under a session is that person's, and the collection's policy decides what a person sees and may change — own (their records only; another's is a 404), read-all-write-own (reads all, changes their own; another's a 403 NOT_OWNER) or read-all (reads only; a write a 403 POLICY_READ_ONLY). Mapled applies the policy; your server acts as the person. In a server component, a route handler or a server action, appForSiteUser() reads the session cookie and answers the live-data client of the person — the same list, get, create, update and remove, each call carrying their session next to the server key — or null when no one is signed in:

// app/saved/page.tsx
import { MapledError } from "@mapled/next";
import { appForSiteUser } from "@mapled/next/server";
import { saveJob } from "./actions";

export default async function SavedJobs() {
  const mine = await appForSiteUser<{ collections: { "saved-jobs": { job: string; note: string } } }>();
  if (!mine) return <p>Sign in to save jobs.</p>;
  let records;
  try {
    ({ records } = await mine.list("saved-jobs", { sort: "-createdAt" }));
  } catch (err) {
    // the cookie outlived its session — signed out elsewhere, expired, suspended: the sign-in page starts over
    if (err instanceof MapledError && err.code === "SESSION_INVALID") return <p>Your session has ended. Sign in again.</p>;
    throw err;
  }
  return (
    <>
      <ul>{records.map((r) => <li key={r.id}>{r.data.job} — {r.data.note}</li>)}</ul>
      <form action={saveJob}><input name="job" /><input name="note" /><button>Save</button></form>
    </>
  );
}
// app/saved/actions.ts
"use server";
import { appForSiteUser } from "@mapled/next/server";

export async function saveJob(form: FormData) {
  const mine = await appForSiteUser();
  if (!mine) throw new Error("Sign in to save jobs.");
  await mine.create("saved-jobs", { job: String(form.get("job")), note: String(form.get("note")) });
}

Every record names its owner. The key alone — db.list(…), db.create(…) on createAppClient — is your backend: it sees every record, with owners, and makes records with none; db.asSiteUser(token) is what appForSiteUser answers, for a token you hold yourself. appForSiteUser asks Mapled nothing to make the client, so a cookie that outlived its session — signed out elsewhere, expired, the account suspended or deleted — is seen on the client's first call: a MapledError with the API's 401 SESSION_INVALID, which the page above treats as signed out. The cookie itself goes on the next identity request — the identity route's session action clears it, signOut() from a server action ends and clears it. getSiteUser says who is signed in up front, but a session can end between that check and the read, so the read is where the code goes. Agents and the browser never reach these records.

Without Next.js: the HTTP API

The client is a thin layer over a plain HTTP API, so any site — or a build script — can read Mapled. The base URL is https://api.mapled.io; every request carries the delivery key in x-mapled-key.

RequestAnswer
GET /v1/delivery/collections/{collection}/records{ records: [{ id, data, expanded? }], total, release }
GET /v1/delivery/collections/{collection}/records/{id}{ record, release } — 404 when it isn't in the release
GET /v1/delivery/collections/{collection}/records/by-slug/{slug}{ record, release }
GET /v1/delivery/singles/{single}{ record, release } — record is null while the single is empty
GET /v1/delivery/release{ release, publishedAt } — the release being served now; nulls before the first publish. With ?webhook=1 also webhook: { release, publishedAt, deliveredAt, cache } of the newest release your site answered the publish webhook for, with the x-mapled-cache mark the answer carried (nulls before the first, and again after the webhook's address changes), or null when the project has no webhook
POST /v1/delivery/forms/{form}201 { ok: true } — see Forms
curl -H "x-mapled-key: $MAPLED_KEY" \
  "https://api.mapled.io/v1/delivery/collections/articles/records?filter[featured]=true&filter[date][gte]=2026-01-01&sort=-date&limit=10&expand=author&fields=title,slug"
  • Lists take filter[field]=value or filter[field][op]=value (eq, ne, lt, lte, gt, gte, in — comma-separated, contains), sort=field / sort=-field, limit (1–100, default 100) and offset. Every read takes expand (relation keys, dotted for a second level), fields, release=N to read a published release by its number and locale=ru to read in one of the project's languages: a translated field answers in it or in the first language of the project's fallback chain that has a value, filters and slug lookups see that language, and the answer carries locale. A query the schema can't answer is a 400 INVALID_QUERY that says why.
  • Only published content of collections with access Public is served; sensitive fields are never in an answer. Image and file values are asset ids — https://api.mapled.io/files/{id} is the file, with ?w=, h=, fit=, fmt=, q= for a resized variant.
  • Answers are per key — Cache-Control: private — with an ETag: send it back as If-None-Match and an unchanged answer is a 304. A read pinned with release=N never changes and may be cached for good. GET /v1/delivery/release is the cheap way to learn that something was published: relay it from a route of your own server, and watchRelease({ endpoint, onPublish }) from @mapled/next — no framework in it — tells open tabs (Live updates).
  • Errors share one envelope: { error: { code, message, requestId } }. A key gets 1,200 requests a minute; past that, 429 RATE_LIMITED with retry-after.
  • The publish webhook is a POST with a JSON body { event, project, release, at, id } and the headers x-mapled-event (release.published, project.restored or test), x-mapled-event-id (the same on every retry — use it to deduplicate), x-mapled-attempt and x-mapled-signature: sha256= followed by the hex HMAC-SHA256 of the raw body with the signing secret. Compare in constant time, answer 2xx within 5 seconds. verifySignature from @mapled/next/server does the check anywhere Web Crypto exists. An answer may carry x-mapled-cache — up to 64 letters, digits, - or _ naming the cache the handler revalidated; Mapled records it with the release, and the ?webhook=1 pointer gives it back.
  • Live data: GET / POST /v1/app/collections/{collection}/records and GET / PATCH / DELETE /v1/app/collections/{collection}/records/{id} with the server key in x-mapled-server-key; writes send { "data": { … } }. On a collection for signed-in users add the person's session token in x-mapled-site-session to act as them (the record carries owner); the key alone is the backend. A session that ended is a 401 SESSION_INVALID.
  • Site users, with the server key too, JSON bodies, tokens never in URLs: POST /v1/app/identity/sign-up { email, password, profile? } → 201 { user }, /v1/app/identity/verify { token } → { user }, /v1/app/identity/sign-in { email, password } → { user, session: { token, expiresAt } }, /v1/app/identity/session { token } → { user, session: { expiresAt } } or 401 SESSION_INVALID (reason: "suspended" when so), /v1/app/identity/sign-out { token } → 204, /v1/app/identity/reset/request { email } → 202, /v1/app/identity/reset { token, password } → 204. Off until a member turns Site Identity on (404 IDENTITY_DISABLED).

Preview (drafts on the site) is built on Next.js draft mode and ships with this package only.

API

  • createClient({ key, apiUrl?, release? }) → getRecords(collection, opts?), getRecord(collection, id, opts?), getRecordBySlug(collection, slug, opts?), getSingle(key, opts?), getRelease(); createClient<MapledSchema>(…) with the generated MapledSchema types every read by its key (MapledClient<S>, MapledAppClient<S> name the typed clients)
  • list opts: filter (a value for equality, or { eq, ne, lt, lte, gt, gte, in, contains } — text takes eq/ne/in/contains, numbers and dates comparisons, booleans eq/ne, image and relation ids eq/ne/in; a one-to-many relation matches when it includes the id), sort ("field" / "-field", not on relations or images), limit (≤100), offset
  • every read: expand (relation keys, dotted for a second level), fields (data keys to keep), release (pin to a published release), locale (read in one of the project's languages, through its fallback chain), revalidate, tags; a query the schema can't answer fails with a 400 INVALID_QUERY message, and errors carry the API's code on MapledError
  • createAppClient({ serverKey, apiUrl? }) → list(collection, opts?), get(collection, id, opts?), create(collection, data), update(collection, id, data), remove(collection, id) — list and get take locale too; every record (and a page of list) carries files — the signed URLs of the private files it names — and on a collection for signed-in users its owner; identity — the site's users: signUp({ email, password, profile? }) → user, verify(token) → user, signIn({ email, password }) → { user, session: { token, expiresAt } }, session(token) → { user, expiresAt } or { user: null, reason: "invalid" | "suspended" }, signOut(token), requestReset(email), reset({ token, password }) (SiteUser, SiteSession, SiteSessionCheck name the types); asSiteUser(token) → the same live data as one signed-in user, their session on every call (MapledSiteUserClient)
  • createIdentityHandler({ serverKey?, apiUrl?, cookie? }) — App Router POST handler of the identity route (a catch-all), keeping the session in the mapled_session cookie; getSiteUser(options?) — who is signed in, from a server component, route handler or server action ({ request } outside Next.js); appForSiteUser(options?) — the live-data client of the person signed in, their session on every call, or null ({ request } outside Next.js too); signIn(input, options?), signUp(input, options?), signOut(options?) — server-action helpers that set and clear the cookie
  • assetUrl(id, { width?, height?, fit?, format?, quality?, apiUrl?, files? }) — URL of an image or file value; with a record's files, the signed URL of a private one
  • createRevalidateHandler({ secret, tags? }) — App Router POST handler
  • createPreviewHandler({ apiUrl?, appUrl? }), createExitPreviewHandler() — App Router GET handlers of the preview routes
  • createReleaseHandler({ key, apiUrl?, ttl?, revalidate? }) — App Router GET handler of the release route; <MapledLive endpoint? interval? onPublish? /> from @mapled/next/live and watchRelease({ endpoint?, interval?, onPublish }) ask it
  • createPagesReleaseHandler({ key, apiUrl?, ttl? }) — Pages Router API route of the release route (the default export of pages/api/mapled/release.ts); <MapledLive endpoint? interval? onPublish? /> from @mapled/next/live/pages asks it
  • verifySignature(secret, body, signature) — if you'd rather build your own handler
  • cacheMark() — the mark of this deployment's Next.js cache: a webhook handler of your own answers with it in x-mapled-cache (createRevalidateHandler does), and createReleaseHandler follows only an answer that carries its own

The delivery key only reads published, public content — safe to use anywhere.

Keywords

mapled

FAQs

Package last updated on 04 Oct 2026

Related posts