diff --git a/docs/assets/seo/default-share-image.png b/docs/assets/seo/default-share-image.png new file mode 100644 index 00000000..0d88d5c6 Binary files /dev/null and b/docs/assets/seo/default-share-image.png differ diff --git a/docs/assets/seo/nfl-score-kickoff.png b/docs/assets/seo/nfl-score-kickoff.png new file mode 100644 index 00000000..de30f115 Binary files /dev/null and b/docs/assets/seo/nfl-score-kickoff.png differ diff --git a/docs/guides/best-practices/seo-and-sharing.mdx b/docs/guides/best-practices/seo-and-sharing.mdx new file mode 100644 index 00000000..c3953968 --- /dev/null +++ b/docs/guides/best-practices/seo-and-sharing.mdx @@ -0,0 +1,507 @@ +# SEO and Sharing for Devvit Posts + +A Devvit post can contain a full interactive app, but the app is not the only part of the post that people see. Search results, link previews, old Reddit, and moderation tools rely on the post title, text fallback, and share image. + +These fields should explain the post before someone opens it. They also make shared links more useful. A generic preview gives the recipient little reason to click, while a matchup, event name, or other useful context shows why the link matters. + +This guide works with any client setup. Your experience can use React, a game engine, another UI library, or no UI library. Titles, text fallback, and share images are generated in server code and do not depend on the client renderer. + +## Compare the preview + +This example uses a live NFL scoreboard. Without a custom share image, the link has a generic Reddit image. With one, the same link shows the matchup and kickoff details. + +The second card is generated by the Satori example in [Generate the share image](#generate-the-share-image). + +
+
+ A Reddit link preview with the default Reddit share image +
+ Before: The preview identifies Reddit, but not the game. +
+
+
+ A custom share image for Seattle at San Francisco before kickoff +
+ After: The preview identifies the game before the link is + opened. +
+
+
+ +This matters anywhere the post is discovered or shared. Search engines get a clearer description of the page, while group chats, messaging apps, and social feeds get a preview that carries useful context on its own. + +## Use a relevant post title + +Write the title for someone who has never seen your app. Include the subject and the purpose of the post. + +For a scoreboard, `Seattle at San Francisco: live score and game thread` is more useful than `Game` or `Live now`. The matchup remains accurate before, during, and after the game. Devvit does not expose an API for changing a post title after creation, so avoid putting a score or game clock in the title. + +Set the title when you [create the custom post](../../capabilities/creating_custom_post.md): + +```ts title="src/server/core/post.ts" +const post = await reddit.submitCustomPost({ + title: "Seattle at San Francisco: live score and game thread", + textFallback: { richtext: createGameFallback(initialGame) }, + styles: { shareImageUrl }, +}); +``` + +## Write a useful post description + +Text fallback is the readable version of an interactive post. The [text fallback documentation](../../capabilities/server/text_fallback.mdx) lists support for old Reddit and third-party clients, Google and Reddit Answers indexing, AutoModerator rules, and Reddit safety checks. + +Use rich text when the fallback needs structure. Keep the copy useful for the life of the post. This scoreboard identifies the matchup and tells readers what they will find inside: + +```ts title="src/server/core/post-preview.ts" +import { RichTextBuilder } from "@devvit/reddit"; + +const createGameFallback = (game: GameState) => + new RichTextBuilder() + .heading({ level: 2 }, (heading) => { + heading.rawText("Seattle at San Francisco"); + }) + .paragraph((paragraph) => { + paragraph.text({ text: `Kickoff: ${game.kickoff}` }); + }) + .paragraph((paragraph) => { + paragraph.text({ + text: "Open the interactive post for the live score, play updates, and game thread.", + }); + }); +``` + +The same pattern works for a tournament bracket, daily puzzle, election tracker, live event schedule, or any post that needs a useful non-interactive description. + +## Generate the share image + +The score card above is rendered inside Devvit compute. It does not use a browser, canvas, or screenshot service. + +The data moves through five steps: + +`post data → Satori element tree → SVG → PNG → Reddit-hosted URL` + +### Install the packages + +```bash title="Terminal" +npm install satori @resvg/resvg-wasm @fontsource/reddit-sans +``` + +| Package | What it does | +| --------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| [`satori`](https://github.com/vercel/satori) | Turns an element tree and font data into an SVG string. It handles layout and text, but it does not create PNGs. | +| [`@resvg/resvg-wasm`](https://github.com/linebender/resvg) | Turns the SVG into PNG bytes. This package is separate from Satori. `initWasm()` loads its renderer. | +| [`@fontsource/reddit-sans`](https://fontsource.org/fonts/reddit-sans) | Supplies the optional Reddit Sans example font used by this guide. | + +Satori includes its own JSX runtime. The TSX in this guide creates Satori's element tree directly. It does not import React or use the client UI framework. This lets the same renderer run alongside a UI library, a game engine, or a plain TypeScript client. + +Reddit Sans is only the font used by this example. You can use any font supported by Satori as long as you include its font data in the `fonts` option. [Fontsource](https://fontsource.org/fonts) packages many other fonts if you do not want to manage the files yourself. + +### Bundle the WASM and fonts + +Devvit runs the server as a single bundle, so the renderer cannot read package files from disk at runtime. This small Vite plugin converts each binary dependency to base64 while the app is building: + +```ts title="tools/inline-binary.ts" +import { readFile } from "node:fs/promises"; +import type { Plugin } from "vite"; + +const query = "?inline-binary"; + +export const inlineBinary = (): Plugin => ({ + name: "inline-binary", + enforce: "pre", + async load(id) { + if (!id.endsWith(query)) return; + + const filePath = id.slice(0, -query.length); + const base64 = (await readFile(filePath)).toString("base64"); + return `export default ${JSON.stringify(base64)};`; + }, +}); +``` + +Add it to the app's Vite plugins: + +```ts title="vite.config.ts" +import { devvit } from "@devvit/start/vite"; +import { defineConfig } from "vite"; + +import { inlineBinary } from "./tools/inline-binary"; + +export default defineConfig({ + plugins: [inlineBinary(), devvit()], +}); +``` + +You do not need a client UI library or its Vite plugin to render an image with Satori. Leave any existing client plugins in place. They are unrelated to the server-side image renderer. + +Add a declaration for imports that use the `?inline-binary` query: + +```ts title="src/server/inline-binary.d.ts" +declare module "*?inline-binary" { + const base64: string; + export default base64; +} +``` + +### Enable TSX for the server renderer + +TSX is TypeScript syntax, not a React dependency. Set the server compiler to use an automatic JSX runtime: + +```json title="src/server/tsconfig.json" +{ + "compilerOptions": { + "jsx": "react-jsx" + } +} +``` + +Despite the option name, this does not install or load React. The `@jsxImportSource` comment in the next file tells TypeScript to use `satori/jsx` for that file. Keeping it local prevents the image renderer from changing how client TSX is compiled. + +### Render a Satori element tree as PNG + +This is the renderer used for the score card in the comparison above. `resvgReady` is created once at module load and awaited before each render. The font files are passed to Satori, while the WASM file initializes Resvg. + +`ScoreCard` is an example, not a required template. Create an image layout that matches your game's or experience's theme. Keep the 1200 by 630 output size and pass the fonts used by the layout to Satori. + +```tsx title="src/server/core/share-image.tsx" +/** @jsxRuntime automatic */ +/** @jsxImportSource satori/jsx */ + +import { media } from "@devvit/web/server"; +import { initWasm, Resvg } from "@resvg/resvg-wasm"; +import satori, { type Font } from "satori"; + +import resvgWasmBase64 from "@resvg/resvg-wasm/index_bg.wasm?inline-binary"; +import redditSansRegularBase64 from "@fontsource/reddit-sans/files/reddit-sans-latin-400-normal.woff?inline-binary"; +import redditSansBoldBase64 from "@fontsource/reddit-sans/files/reddit-sans-latin-900-normal.woff?inline-binary"; + +type GameState = { + awayScore: number; + clock: string; + detail: string; + homeScore: number; + kickoff: string; + period: string; + status: "Pregame" | "Live" | "Halftime" | "Final"; +}; + +type ShareImageData = GameState & { + generatedAt: Date; +}; + +type Team = { + abbreviation: string; + city: string; + color: string; +}; + +const awayTeam: Team = { + abbreviation: "SEA", + city: "Seattle", + color: "#69be28", +}; +const homeTeam: Team = { + abbreviation: "SF", + city: "San Francisco", + color: "#aa0000", +}; + +const decodeBase64 = (base64: string) => + Uint8Array.from(Buffer.from(base64, "base64")); + +const resvgReady = initWasm(decodeBase64(resvgWasmBase64)); +const fonts: Font[] = [ + { + name: "Reddit Sans", + data: Buffer.from(redditSansRegularBase64, "base64"), + weight: 400, + style: "normal", + }, + { + name: "Reddit Sans", + data: Buffer.from(redditSansBoldBase64, "base64"), + weight: 900, + style: "normal", + }, +]; + +const frame = { + border: "5px solid #111111", + boxShadow: "10px 10px 0 #111111", +}; +const dateFormatter = new Intl.DateTimeFormat("en-US", { + hour: "numeric", + hour12: true, + minute: "2-digit", + timeZone: "America/New_York", + timeZoneName: "short", +}); + +const statusColor = (status: GameState["status"]) => { + if (status === "Live") return "#ff4500"; + if (status === "Final") return "#69be28"; + return "#ffde59"; +}; + +const TeamScore = ({ score, team }: { score: number; team: Team }) => ( +
+
+ {team.city} + + {team.abbreviation} + +
+ + {score} + +
+); + +const ScoreCard = ({ game }: { game: ShareImageData }) => ( +
+
+
+ NFL WEEK 12 · LIVE GAME THREAD +
+
+ {game.status} +
+
+ +
+ +
+ AT + + {game.period} + + {game.clock === game.period ? null : {game.clock}} +
+ +
+ +
+ {game.detail} + + Generated {dateFormatter.format(game.generatedAt)} + +
+
+); + +export const renderShareImagePng = async (data: ShareImageData) => { + await resvgReady; + + const svg = await satori(, { + width: 1200, + height: 630, + fonts, + }); + const renderer = new Resvg(svg); + const image = renderer.render(); + + try { + return image.asPng(); + } finally { + image.free(); + renderer.free(); + } +}; + +export const uploadShareImage = async (game: GameState) => { + const png = await renderShareImagePng({ ...game, generatedAt: new Date() }); + const uploaded = await media.upload({ + type: "image", + url: `data:image/png;base64,${Buffer.from(png).toString("base64")}`, + }); + return uploaded.mediaUrl; +}; +``` + +Satori uses a flexbox-like subset of CSS rather than a browser layout engine. Keep the image at a fixed size and pass every font used by the element tree in the `fonts` option. + +The pragmas select Satori's JSX runtime for this file. No React package or plugin is involved. If every TSX file in the server uses Satori, you can set `"jsxImportSource": "satori/jsx"` in the server `tsconfig` instead of adding `@jsxImportSource` to each file. + +
+ Use plain TypeScript instead of TSX + +Satori also accepts plain element objects. The object must have a `type` and `props`, with nested elements in `props.children`: + +```ts title="src/server/core/share-image.ts" +const scoreCard = (game: ShareImageData) => ({ + type: "div", + props: { + style: { + display: "flex", + fontFamily: "Reddit Sans", + height: "100%", + width: "100%", + }, + children: [ + { + type: "span", + props: { + children: `SEA ${game.awayScore} · SF ${game.homeScore}`, + style: { fontSize: 96, fontWeight: 900 }, + }, + }, + ], + }, +}); + +const svg = await satori(scoreCard(game), { + width: 1200, + height: 630, + fonts, +}); +``` + +Both forms produce the same kind of Satori element tree. Choose TSX for a more readable image layout or plain objects when you want to keep the renderer in a `.ts` file. + +
+ +## Set the share image when creating the post + +The [media API](../../capabilities/server/media-uploads.mdx) accepts the PNG data URL and returns a Reddit-hosted URL. Enable media uploads in your app configuration: + +```json title="devvit.json" +{ + "permissions": { + "media": true + } +} +``` + +Generate and upload the image before creating the post, then use the returned URL in the post styles: + +```ts title="src/server/core/post.ts" +import { reddit } from "@devvit/web/server"; + +export const createGamePost = async (game: GameState) => { + const shareImageUrl = await uploadShareImage(game); + + return reddit.submitCustomPost({ + title: "Seattle at San Francisco: live score and game thread", + textFallback: { richtext: createGameFallback(game) }, + styles: { shareImageUrl }, + }); +}; +``` + +`media.upload()` returns the Reddit-hosted URL used by `submitCustomPost()`. Custom share images are available for posts in public subreddits. + +Generate one share image for each post and treat it as a snapshot. Social apps, messaging clients, and search engines cache link metadata on their own schedules, so changing the URL later may not replace previews that have already been cached. Keep live scores and other changing data inside the interactive post. + +Titles, text fallback, and share images have different jobs. The title gives the post a durable identity. The fallback provides a readable description. The share image makes the post easy to recognize when its link is shared. diff --git a/sidebars.ts b/sidebars.ts index 6d18f15a..a21d9266 100644 --- a/sidebars.ts +++ b/sidebars.ts @@ -306,6 +306,7 @@ const sidebars: SidebarsConfig = { label: "Best Practices", items: [ "guides/best-practices/community_games", + "guides/best-practices/seo-and-sharing", "guides/best-practices/mod_resources", "capabilities/server/text_fallback", ],