@risali/react
v1.7.0
Published
React server components for Risali.app — SSR-fetch editable text/image/rich-text blocks from a client's Risali site.
Maintainers
Readme
@risali/react
React Server Components for Risali.app — fetch editable text/image/rich-text blocks from a client's Risali site at SSR time.
Built for Next.js 15 App Router (React 19). Zero client-side JavaScript: every block is rendered server-side, the editable markers ship in plain HTML.
Install
npm install @risali/reactConfigure
Set the site slug once, in your project's .env:
RISALI_SITE_SLUG=mojweb
# Optional — defaults to https://app.risali.app
RISALI_API_BASE=https://app.risali.app
# Optional — Next.js fetch revalidate seconds (default 60)
RISALI_REVALIDATE_SECONDS=60NEXT_PUBLIC_RISALI_SITE_SLUG is also accepted if you'd rather expose it.
Usage
Text
// app/page.tsx
import { RisaliText } from "@risali/react";
export default function HomePage() {
return (
<section>
<RisaliText
as="h1"
pageKey="hero_title"
defaultValue="Vitajte na našom webe"
path="/"
className="text-5xl font-bold"
/>
<RisaliText
pageKey="hero_subtitle"
defaultValue="Robíme weby, ktoré klient sám upravuje."
path="/"
/>
</section>
);
}The component fetches once per (slug, path) pair — Next.js's built-in fetch cache deduplicates identical URLs in a request, so multiple <RisaliText> calls on the same page share one network call.
Image
import { RisaliImage } from "@risali/react";
<RisaliImage
pageKey="hero_photo"
defaultSrc="/hero-fallback.jpg"
defaultAlt="Naša prevádzka"
path="/"
width={1200}
height={600}
className="rounded-2xl"
/>;Only http(s)://… and /local-path sources from the Risali block are honoured; anything else falls back to defaultSrc.
Rich text (bold / italic / palette colour)
import { RisaliRichText } from "@risali/react";
<RisaliRichText
as="div"
pageKey="about_body"
defaultValue="<p>Sme tu už <strong>10 rokov</strong>.</p>"
path="/o-nas"
/>;Block HTML is sanitised on the server before render. Whitelisted tags: span, strong, b, em, i, u, br, a, p. Whitelisted attributes: style (only color:#RRGGBB + font-size:<n>px|rem|em) and href (only http(s)://, mailto:, tel:, or /local). Everything else — <script>, <img> (use <RisaliImage> instead), onerror, javascript:, style="background: url(…)" — is stripped.
Sharing one fetch across many components
If a page renders many Risali blocks, you can pull content once and thread it down — the components skip their own fetch when content is provided:
import { getRisaliContent, RisaliText, RisaliImage } from "@risali/react";
export default async function HomePage() {
const content = await getRisaliContent("/");
return (
<>
<RisaliText content={content} pageKey="hero_title" defaultValue="Vitajte" />
<RisaliImage content={content} pageKey="hero_photo" defaultSrc="/h.jpg" />
</>
);
}Pure helpers for client components ("use client")
The <Risali*> components are async server components — they can't be used directly inside a "use client" boundary. For those cases, fetch content once in your server page and use the pure helpers inline:
// app/page.tsx — server component
import { getRisaliContent } from "@risali/react";
import { HomePage } from "@/components/HomePage";
export default async function Page() {
const content = await getRisaliContent("/");
return <HomePage content={content} />;
}
// components/HomePage.tsx — client component
"use client";
import { getBlockValue, getBlockImage, getBlockRichText } from "@risali/react";
import type { RisaliContent } from "@risali/react";
export function HomePage({ content }: { content: RisaliContent | null }) {
const heroTitle = getBlockValue(content, "hero_title", "Vitajte");
const heroPhoto = getBlockImage(content, "hero_photo", { src: "/h.jpg", alt: "Hero" });
const aboutHtml = getBlockRichText(content, "about_body", "<p>O nás</p>");
return (
<>
<h1 data-risali-key="hero_title">{heroTitle}</h1>
<img data-risali-key="hero_photo" src={heroPhoto.src} alt={heroPhoto.alt} />
<div data-risali-key="about_body" dangerouslySetInnerHTML={{ __html: aboutHtml }} />
</>
);
}Helpers always validate the block type (a block typed image will return defaultValue from getBlockValue) and getBlockRichText always sanitises — including the fallback HTML — so a stored XSS payload can't reach the browser even if the block is missing.
Builder pages (v0.7.0)
A page with render_mode='builder' in the Risali dashboard is a DB-driven list of sections. Built-in section types: hero, text_image, features, gallery, cta, contact, plain, plus (v0.7.0) testimonials, pricing, faq, stats, team and logo_cloud. Every section is styled inline from the brand palette (gradients / shadows / radius derived via color-mix() from --risali-color-*) and is responsive without media queries, so it looks designed even without the host app's Tailwind theme. <RisaliPage> renders the whole page server-side; pages still in code mode return notFound, so a catch-all route coexists with your static routes:
// app/[[...rest]]/page.tsx
import { notFound } from "next/navigation";
import { RisaliPage } from "@risali/react";
export default async function Page({ params }: { params: Promise<{ rest?: string[] }> }) {
const { rest } = await params;
const path = "/" + (rest ?? []).join("/");
const page = await RisaliPage({ path });
if (!page) notFound();
return page;
}Signature sections you build in code register via customSections={{ moja_sekcia: MyComponent }} and win over the built-in catalog on a type collision.
Who owns the section's spacing
Built-in sections (hero, features, cta, …) get their vertical + horizontal rhythm from the <section> wrapper <RisaliPage> puts around them — the dashboard "spacing" control (compact / normal / spacious) drives that padding.
Custom sections own their own spacing. A signature component ships its own py-* / px-* (or container), so <RisaliPage> gives its wrapper no padding — otherwise the wrapper padding and the component padding would stack (e.g. 96px wrapper + 64px component = 160px of dead space on each side). The wrapper still contributes everything the component can't set from the outside: data-risali-section* markers, tone / palette CSS variables, font-family, and the section background (colour / image / overlay) from settings.
If a custom section wants the wrapper's spacing (e.g. it's a thin content block that should follow the dashboard "spacing" setting), opt in at registration:
customSections={{
// no wrapper padding — the component handles it (default)
hero_signature: HeroSignature,
// wrapper applies SPACING_PADDING from settings.spacing, like a built-in
promo_band: { component: PromoBand, wrapperSpacing: true },
}}You can also turn wrapper padding off for any section — built-in included — from the data side with settings.spacing: "none" (the DB escape hatch; the dashboard picker still offers compact / normal / spacious).
⚠️ Breaking change in v0.20.0. Before v0.20.0 the wrapper applied
SPACING_PADDINGto every section, custom ones included, so signature sections were double-padded. If your site worked around it with a CSS override likesection[data-risali-section] { padding: 0 !important; }, remove that override after upgrading — it now also cancels the dashboard "spacing" control for your built-in sections. Sites that use only built-in sections are unaffected.
<RisaliBrand /> in the root layout emits the brand palette + fonts as CSS variables (--risali-color-<key>, --risali-font-heading, --risali-font-body) — builder sections consume them automatically, and your own components/Tailwind theme can too. A brand change in the dashboard revalidates the site instantly, no deploy.
Editor mode
Every rendered block carries a data-risali-key="<pageKey>" attribute. The Risali editor iframe (/risali.js?site=<slug>&risali_edit=1) uses that marker to wire up click-to-edit — no CSS selector generation, no content-hash drift, no fragile DOM scanning.
What this package does NOT do
- No client-side DOM patching. Content lives in the server-rendered HTML; the visitor browser never re-renders Risali content.
- No analytics or form capture. Those still ship via
<script async src="https://app.risali.app/risali.js?site=…"></script>(the Risali snippet handles pageview events, form beacons, the cookie banner, and the editor iframe). - No widgets (booking, pricelist, contact form) — yet. Coming in a follow-up package release.
Versioning contract (since 1.0.0)
Client repos pin this package with a caret range ("@risali/react": "^1.0.0"), so a
npm install at deploy time resolves the newest 1.x automatically — no per-repo
package.json edit to pick up a new section type or variant. That only works if the
version numbers carry a stable, additive meaning. Since 1.0.0 they do:
- Minor (
1.1.0,1.2.0, …) — always additive. A new section type, a new variant of an existing type, a new optional prop, a new export. Existing markup, props and rendered output stay byte-for-byte compatible. Safe to pick up automatically via^1. - Patch (
1.0.1, …) — bug/render fixes that don't change the public API or the shape of the emitted HTML in a breaking way. - Major (
2.0.0, …) — breaking render. Removed/renamed export or prop, changed DOM structure a client web depends on, changed default behaviour. A major requires bumping the caret range in each repo deliberately (that's theMAJORpath in/bump-fleet).
Why
1.0.0and not0.x: npm's caret is treacherous below1.0.0—^0.25resolves only0.25.x, never0.26+. Every skipped0.xminor also riskedETARGETat install time. From1.0.0,^1spans every future minor, so additive releases flow to the fleet at the next deploy with zero file edits.
Split catalog — render side is always a superset
The render side (this package's sections/index.tsx / RISALI_SECTION_COMPONENTS)
must always be a superset of the author side (the dashboard's
src/lib/sections/catalog.ts + variants.ts). Because caret pins float, a client web can
run a package newer than the dashboard generator that scaffolded it — that is fine and
expected: the renderer knows how to draw everything the author side can emit, and more.
The reverse (author emits a type the renderer doesn't know) is what breaks, so publish
the renderer minor before shipping the authoring UI that produces it. The load-time guard
in variants.ts already enforces this direction.
⚠️ Publish before scaffold. A new web scaffolded with
^1.0.0failsnpm installwithETARGETuntil1.0.0(or any1.x) is actually published to npm. Publish the1.xrelease first, then scaffold.
License
Closed-source. Used by Risali.app clients under their SaaS subscription.
