diff --git a/docs/.vitepress/sidebarTutorials.js b/docs/.vitepress/sidebarTutorials.js index 03c6fa97..35160701 100644 --- a/docs/.vitepress/sidebarTutorials.js +++ b/docs/.vitepress/sidebarTutorials.js @@ -148,9 +148,13 @@ const sidebarTutorials = [ link: 'tutorials/reusing-standard-fields.md' }, { - text: 'Building a JSX Widget', + text: 'Building a Client-side JSX Widget', link: 'tutorials/using-jsx-in-apostrophe.md' }, + { + text: 'Building a Server-side JSX Widget', + link: 'tutorials/server-side-jsx.md' + }, { text: 'Local Extension Development', link: 'tutorials/local-extension-development.md' diff --git a/docs/tutorials/server-side-jsx.md b/docs/tutorials/server-side-jsx.md new file mode 100644 index 00000000..7c984f5f --- /dev/null +++ b/docs/tutorials/server-side-jsx.md @@ -0,0 +1,428 @@ +--- +next: false +prev: false +title: "Building a Weather Widget with Server-Side JSX" +detailHeading: "Tutorial" +url: "/tutorials/server-side-jsx.html" +content: "Build a server-rendered weather widget using ApostropheCMS's JSX template system. Fetch live data with async/await directly in the template, compose the UI from inline sub-components, and drop the result into your existing Nunjucks layout — no Vite config, no widget player, no client-side JavaScript required." +tags: + topic: "Advanced Techniques" + type: tutorial + effort: intermediate +--- + +# Building a Weather Widget with Server-Side JSX + +ApostropheCMS's JSX template system lets you write widget and page templates in JSX instead of Nunjucks. These templates run **on the server** and produce plain HTML — there is no React runtime shipped to the browser, no widget player to write, and no new Vite configuration needed for the template logic itself. + +This tutorial focuses on three capabilities that make server-side JSX particularly useful: + +1. **Fetching external data directly in the template** using `async`/`await` — no async component wrapper required +2. **Composing markup from inline sub-components** defined in the same file, treating the template as a real JavaScript module +3. **Extending an existing Nunjucks layout from a JSX page template** via ``, so you can migrate page by page instead of all at once + +If you've read the [browser-side React weather widget tutorial](/tutorials/using-jsx-in-apostrophe.html), you'll recognize the example, it's the same weather widget. The contrast is the point: that tutorial needed changes to the project Vite configuration, a widget player, `createRoot`, and a proxy API route to render the same weather data. This one does the same rendering in a single `.jsx` file with no client JavaScript at all. + +::: info When to use each approach +Use **server-side JSX templates** (this tutorial) when your widget renders content that does not need to change after the page loads. The output is static HTML with nothing running in the browser. + +Use **browser-side React** when you genuinely need post-load interactivity: user input that triggers re-renders, real-time data updates, client state, and so on. Server-side JSX is not a replacement for React, it is a replacement for Nunjucks. +::: + +## What we're building + +A "Current Conditions" widget that an editor can place in any area. The editor enters a city and selects a temperature unit in the widget schema. When a page request comes in, the widget template fetches live weather data from the OpenWeatherMap API, formats it using small helper components defined inline, and renders the result as plain HTML, all before the response is sent. + +## Prerequisites + +- An ApostropheCMS project running a version that supports JSX templates +- An [OpenWeatherMap](https://openweathermap.org/) API key (the free tier is sufficient) +- Comfort reading JSX; this tutorial explains the Apostrophe-specific parts but not JSX syntax itself + +## Step 1: Create the widget module + +```sh +apos add widget weather-conditions +``` + +No `--player` flag is needed since there is no browser-side JavaScript for this widget. + +Register it in `app.js`: + + + +```javascript +import apostrophe from 'apostrophe'; + +export default apostrophe({ + shortName: 'my-project', + modules: { + // ... other modules + 'weather-conditions-widget': {} + } +}); +``` + + + + + +## Step 2: Define the schema fields + +Open `modules/weather-conditions-widget/index.js` and add the two fields an editor will configure: + + + +```javascript +export default { + extend: '@apostrophecms/widget-type', + options: { + label: 'Current Conditions' + }, + fields: { + add: { + city: { + type: 'string', + label: 'City', + required: true, + def: 'Philadelphia' + }, + units: { + type: 'select', + label: 'Temperature units', + def: 'metric', + choices: [ + { label: 'Celsius (°C)', value: 'metric' }, + { label: 'Fahrenheit (°F)', value: 'imperial' }, + { label: 'Kelvin (K)', value: 'standard' } + ] + } + } + } +}; +``` + + + + + +The module configuration is now complete. + +## Step 3: Write the JSX template + +Create `modules/weather-conditions-widget/views/widget.jsx`. This is the whole widget with data fetching, sub-components, and final markup in one file. + + + +```jsx +// ─── Inline sub-components ──────────────────────────────────────────────────── + +function StatBlock({ label, value }) { + return ( +
+ {value} + {label} +
+ ); +} + +function ErrorState({ city, message }) { + return ( +
+

Could not load weather for {city}.

+

{message}

+
+ ); +} + +// ─── Main template ──────────────────────────────────────────────────────────── + +export default async function({ widget }, { apos }) { + const { city, units = 'metric' } = widget; + const apiKey = process.env.OPENWEATHERMAP_API_KEY; + const cacheKey = `${city}:${units}`; + + // Check the cache before hitting the API. Because this function is async, + // we can await here just like any other JavaScript — no async component + // wrapper needed. + let weather = await apos.cache.get('weather', cacheKey); + + if (!weather) { + try { + const url = + 'https://api.openweathermap.org/data/2.5/weather?' + + new URLSearchParams({ q: city, units, appid: apiKey }); + const res = await fetch(url); + if (!res.ok) { + throw new Error(`OpenWeatherMap returned ${res.status}`); + } + weather = await res.json(); + await apos.cache.set('weather', cacheKey, weather, 600); + } catch (err) { + return ; + } + } + + const unitLabel = { metric: '°C', imperial: '°F', standard: 'K' }[units]; + const temp = Math.round(weather.main.temp); + const description = weather.weather[0].description; + const icon = weather.weather[0].icon; + const iconUrl = `https://openweathermap.org/img/wn/${icon}@2x.png`; + + return ( +
+
+

+ {weather.name}, {weather.sys.country} +

+ +
+ +

+ {temp}{unitLabel} + {description} +

+ +
+ + + +
+
+ ); +} +``` + + + +
+ +Let's step through the main highlights of the code. + +**The `async` keyword on the export function** is all it takes to unlock `await` inside a template. There is no async component to register in `index.js` like you would do with a Nunjucks template, no `beforeSend` hook to write, and no data-passing mechanism to thread through. The function fetches data, handles errors, and returns markup — in one place. + +**The cache is checked before the API call** using `apos.cache.get('weather', cacheKey)`. A weather API call on every page request is fine for low-traffic pages, but busier ones should avoid depending on an external API for every render. If a cached value exists, `weather` is populated and the `fetch` block is skipped entirely; otherwise the template fetches fresh data and stores it with `apos.cache.set('weather', cacheKey, weather, 600)`, expiring after 600 seconds (10 minutes). The cache key includes both `city` and `units` so different widget configurations don't collide. + +**`StatBlock` and `ErrorState` are plain JSX functions** defined at the top of the same file. They work exactly like React function components but they run on the server and produce strings. This inline approach works well for small, single-use partials. For reusable components, you have two alternatives: a standard JavaScript `import` if the component lives in its own file and doesn't need name-based resolution, or Apostrophe's `