Custom sections are React (TSX) modules you add to a Lockii-hosted website when a built-in layout is not enough. They import from @lockii/site-sdk and run inside the same providers as catalog sections, so they can read your products, locations, hire dates, and theme without talking to the network themselves.
Built-in layouts should still be the default. Write code for a layout the catalog does not cover, or ask the editor's AI to fork an existing section and rewrite it.
Custom code is sandboxed. It cannot fetch URLs, read cookies or storage, touch
window/document, or render raw links and forms. Use the SDK hooks and primitives below instead.
Open the code editor
Go to
Websiteand open the page you want to edit.Add a layout (the layout browser).
Click Write code.
Give the section a name. Pick a starter: Content, Products, or Banner.
Edit the TSX. The preview updates after a successful compile.
Save to add it to your library. It is inserted on the page you were editing.
You can also customize an existing layout with AI from the same browser. AI always starts from that section's real source and must still pass the same linter.
Saved custom sections live in your library. Editing one updates every page that uses it. Archiving removes it from the library; pages cannot keep a section that is not ready.
Anatomy of a section
Every module must end with export default defineSection({...}). Imports are limited to react and named exports from @lockii/site-sdk.
import {
defineSection,
fields,
SiteContainer,
SiteHeading,
} from "@lockii/site-sdk";
export default defineSection({
id: "custom.starter",
label: "Custom section",
category: "Custom",
fields: {
headline: fields.text({ label: "Headline", ai: { rewrite: true } }),
body: fields.textarea({ label: "Body", ai: { rewrite: true } }),
},
defaults: {
headline: "Your headline",
body: "Tell customers what makes you different.",
},
render: ({ headline, body }) => (
<section className="sec-section">
<SiteContainer>
<SiteHeading level={2}>{headline}</SiteHeading>
<p style={{ color: "var(--site-paragraph)" }}>{body}</p>
</SiteContainer>
</section>
),
});Key | Purpose |
|---|---|
| Cosmetic in source. On save, Lockii stores the section as |
| Name in the layout browser and editor. |
| Sidebar grouping. Custom work usually uses |
| Sidebar controls merchants can edit without opening code. |
| Initial values for those fields. |
| React function. Receives the field values as props. May call SDK hooks. |
Keep copy, images, and buttons in fields so the page editor can change them. Keep catalog data in hooks so the section stays in sync with hire dates, location, and stock.
The source must stay under 30,000 characters.
Hook into live data
Do not hardcode product or location ids in JSX. Pass a field value into a hook, or let the hook use the current page and browse session.
Company, page, and theme
useSite() returns the host context. useSiteOptional() is the same value or null if the provider is missing (it is always present on hosted pages).
Path | What you get |
|---|---|
| Business name |
| Currency code |
| Logo URL, or |
| Public contact details when set |
|
|
|
|
| Page slug |
| Bound product / location / category on template pages |
|
|
| Header nav items |
| Prefix for in-site links (empty on a custom domain) |
| Prefix for browse / checkout links |
| Theme tokens already applied as CSS variables |
usePageEntity() is a shortcut for useSite().page.entity on product, location, and category templates.
Products
const products = useProducts({ limit: 6 });
const product = useProduct(productId);useProducts(filter?) returns SiteProduct[] for this company. Availability is already computed for the current hire window and location.
Filter | Effect |
|---|---|
| Max rows after sort |
| Restrict to a category |
| Restrict to a location. If omitted, the page entity location or the browse-session location is used |
| Pin specific products ( |
|
|
|
|
| Drop unavailable / unknown, and drop |
Each SiteProduct includes id, name, description, href (product page), bookHref, imageUrl, imageUrls, priceLabel, priceAmountLabel, priceSubtitle, category, categoryId, locationIds, and availability.
useProduct(id) returns one product or null. useProductAvailability(productId) returns just the availability object.
Locations and categories
const locations = useLocations();
const location = useLocation(locationId);
const categories = useCategories();SiteLocation includes id, name, address, hoursLabel, href, bookHref, lat, lng, and optional availableProductCount.
SiteCategory includes id, name, href, and optional productCount.
Browse session (dates, location, cart)
Hire dates and the selected location are shared across the site via the browse session. Catalog hooks already read that session. To show the date and location controls, render SiteFilterBar. To read the current values:
const browse = useSiteBrowseOptional();
const locationId = browse?.locationId; // "all" or a location id
const from = browse?.dateRange.from; // epoch ms, or undefined
const to = browse?.dateRange.to;Prefer useSiteBrowseOptional() over useSiteBrowse(). The optional hook returns null instead of throwing if the provider is missing.
You cannot attach onClick handlers in custom sections (see Sandbox rules). Do not rebuild date pickers, location pickers, or checkout. SiteFilterBar, SiteProductCard, and SiteButton already write the session and build the right URLs.
Show products and availability
import {
defineSection,
fields,
SiteContainer,
SiteHeading,
SiteProductCard,
useProducts,
} from "@lockii/site-sdk";
export default defineSection({
id: "custom.starter",
label: "Custom products",
category: "Custom",
fields: {
headline: fields.text({ label: "Headline", ai: { rewrite: true } }),
limit: fields.number({ label: "How many products" }),
},
defaults: {
headline: "Popular gear",
limit: 6,
},
render: ({ headline, limit }) => {
const products = useProducts({ limit });
return (
<section className="sec-section">
<SiteContainer>
<SiteHeading level={2}>{headline}</SiteHeading>
<div
style={{
display: "grid",
gap: "var(--site-item-spacing)",
gridTemplateColumns: "repeat(auto-fill, minmax(240px, 1fr))",
}}
>
{products.map((product) => (
<SiteProductCard key={product.id} product={product} />
))}
</div>
</SiteContainer>
</section>
);
},
});product.availability.kind is unknown, stocked, available, limited, or unavailable. product.availability.label is ready to print. product.availability.units is the count behind that label.
Use
SiteAvailabilityBadgewithavailability={product.availability}for the standard badge. It renders nothing forunknownor an empty label.Cards hide the badge until a hire window is set (
stocked/unknownare inventory, not a dated hire status).siteAvailabilityBookable(availability)istrueforavailableandlimited.Call
siteAvailability()only if you already have raw stock and reservation rows. You will not, in normal custom sections. Prefer the field onSiteProduct.
SiteProductCards can wrap a list the same way built-in grids do. Passing products={products} without a nested card slot renders a SiteProductCard per item.
Buttons and destinations
Do not render <a>, <form>, or <button onClick>. Use SiteButton with a fields.action() value. The SDK resolves that action to a safe URL, including browse dates and location when they are set.
<SiteButton action={cta}>{cta.label}</SiteButton>Default the action in defaults so the sidebar has something to edit:
cta: { type: "browse", label: "Browse gear" },
| Extra fields | Destination |
|---|---|---|
| Browse / book flow, with current dates and location | |
|
| Checkout when dates and a location are chosen; otherwise browse for that product |
|
| Product page |
|
| Location page |
|
| Category page |
| Browse | |
| Locations index | |
| Browse | |
| Customer account | |
|
| Another site page |
|
| New tab. Only |
|
|
|
|
|
|
SiteButton variants are primary (default) and outline. Optional size is sm, md, or lg.
If you need a URL for SiteHeading's href (allowed, unlike <a>), call resolveAction(action, site, browse) and use the returned href. Prefer SiteButton for anything that looks like a call to action.
UI primitives you can import
These are the only components on the coded-section allowlist. They already follow the site theme.
Component | Role |
|---|---|
| Centered page-width wrapper ( |
|
|
| CDN image by media id ( |
| Action link styled as a button |
| Product card with gallery, price, availability, and book link |
| Grid of product cards |
| Location card. Do not pass |
| Shared location + hire-date controls |
| Availability chip |
Wrap the block in <section className="sec-section"> so it gets the same vertical padding as catalog sections.
fields.image() stores { src, overlay? }. Pass that into SiteImage:
<SiteImage alt="" imageId={photo.src} overlay={photo.overlay} />Fields merchants can edit
fields.* helpers become sidebar controls. Put a matching key in defaults.
Helper | Stored value | Editor control |
|---|---|---|
|
| Single line |
|
| Multiline |
|
| Number |
|
| Yes / No |
|
| Dropdown. |
|
| Radio group |
|
| Destination picker |
|
| Upload + overlay |
| product id | Product picker |
| location id | Location picker |
|
| Dynamic or static product selection |
| array of objects | Repeater |
| nested object | Group |
| icon name | Icon picker |
| palette token or empty (inherit) | Color |
| color / gradient / image | Section backdrop |
|
| Padding sliders |
Set ai: { rewrite: true } on copy fields you are happy for AI rewrite tools to change. Skip it on ids, counts, and actions.
A product field plus useProduct is the safe way to bind one item:
fields: {
productId: fields.product({ label: "Product" }),
},
defaults: {
productId: "",
},
render: ({ productId }) => {
const product = useProduct(productId);
if (!product) {
return (
<section className="sec-section">
<SiteContainer>
<p style={{ color: "var(--site-paragraph)" }}>
Choose a product in the sidebar.
</p>
</SiteContainer>
</section>
);
}
return (
<section className="sec-section">
<SiteContainer>
<SiteProductCard product={product} />
</SiteContainer>
</section>
);
},Some fields.* helpers exist for built-in header and card chrome (navItems, companyLogo, headerLayout, productCardChrome, slot). They are not useful in a custom section. Stick to the table above.
Styling with the theme
Colors must be CSS variables from the site theme. Hex colors (#111827) and arbitrary Tailwind values (w-[37px], bg-[red]) fail the linter.
Use className="sec-section" on the outer <section>, SiteContainer for width, and inline style objects for the rest:
<section
className="sec-section"
style={{ background: "var(--site-foreground)" }}
>
<SiteContainer>
<SiteHeading level={2}>{headline}</SiteHeading>
<p style={{ color: "var(--site-paragraph)" }}>{body}</p>
<SiteButton action={cta}>{cta.label}</SiteButton>
</SiteContainer>
</section>Existing sec-* and sui-* classes from the design system are allowed. Do not add a <style> tag.
Token | Typical use |
|---|---|
| Page background |
| Alternate band behind a section |
| Card fill |
| Headings |
| Body copy |
| Brand accent |
| Primary button |
| Dividers |
| Rounding |
| Gaps in grids |
| Default section padding (already on |
| Body font |
| Display font |
SiteHeading and SiteButton also accept a color prop with palette roles lightest, light, accent, dark, and darkest.
Sandbox rules
Custom sections are compiled on the server, linted, then evaluated with a frozen SDK object. The editor preview runs in a sandboxed iframe with scripts disabled. The live site still only injects react plus the allowlisted SDK. That is the whole surface, even if something slipped past the linter.
Imports
Allowed module specifiers: react and @lockii/site-sdk.
Named SDK imports must be from this list:
UNKNOWN_SITE_AVAILABILITY, SiteAvailabilityBadge, SiteButton, SiteContainer, SiteFilterBar, SiteHeading, SiteImage, SiteLocationCard, SiteProductCard, SiteProductCards, defineSection, fields, resolveAction, siteAvailability, siteAvailabilityBookable, useCategories, useLocation, useLocations, usePageEntity, useProduct, useProductAvailability, useProducts, useSite, useSiteBrowse, useSiteBrowseOptional, useSiteOptional
No default import from the SDK. No import * as sdk. No import(), import.meta, or require.
Not allowed
Blocked | Use instead |
|---|---|
| SDK hooks |
|
|
| Nothing. Sections are stateless besides field props and the browse session |
| Ordinary TSX |
| Arrays and named props |
Computed access like |
|
JSX spreads ( | List props explicitly |
|
|
|
|
|
|
Hooks from react (useMemo, and so on) are fine. They still cannot reach the DOM or the network.
If compile fails, the preview stays empty and the section cannot be saved as ready. Fix the listed line errors and wait for the preview.
Save and publish
Compile must succeed (no lint errors, preview visible).
Save adds the section to your library as
custom.{id}.The section is a draft on the page until you publish that page, same as any other layout change.
Later edits to the section source apply everywhere it is used, after a successful compile.
Custom sections are per company. They are not a public npm package and they do not run in the dashboard origin.
Related
Ways To Implement Lockii — hosted site vs widgets vs your own stack
Setting Up a Custom Domain — serve the hosted site on your domain
Embedding Widgets On Your Site — book on a site you already have, without custom sections
