September 11, 2026

Automate social images with SvelteKit remote functions

The RenderFast Team

If you’ve wired up a +server.ts route just to proxy a POST request to a third-party API, you know the pattern: define the route, parse the body, attach a header, forward the request, return the JSON, then write a matching fetch call on the client that has to stay in sync with all of it. SvelteKit’s remote functions collapse that into one typed function you call directly from your component, no route file, no manual fetch, no keeping two ends of a contract in sync by hand.

This tutorial builds the same tool as its companion post, an internal page where you pick a template, fill in a form, preview the rendered image, and download it, except the glue between your SvelteKit app and the RenderFast API is a command() from $app/server instead of an API route.

What you’ll build

  • A .remote.ts file exporting a command() that validates its input with Valibot and calls RenderFast’s /api/generate endpoint from the server
  • A component that calls that command directly from a button handler and stores the result in $state
  • The same preview and download UI as the server-route version

Prerequisites

  • Node.js 20 or newer
  • A RenderFast account with a template ready to render, and its Template ID copied from the editor URL (see the quickstart guide if you need to clone one first)
  • An API key from App → API Keys
  • Familiarity with Svelte 5 runes ($state, $props) helps, but nothing here goes beyond what you’d use in any form component

Remote functions are still an experimental SvelteKit feature, so you’ll need a recent SvelteKit version and an explicit opt-in, covered below. If you already read the server-routes version of this tutorial, the RenderFast side of things is identical, the only thing changing here is how your SvelteKit app talks to itself.

Create the project and enable remote functions

npx sv create social-image-tool
cd social-image-tool
npm install valibot

Remote functions live behind two flags: kit.experimental.remoteFunctions and compilerOptions.experimental.async. Add both to svelte.config.js:

// svelte.config.js
import adapter from '@sveltejs/adapter-auto';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';

/** @type {import('@sveltejs/kit').Config} */
const config = {
	preprocess: vitePreprocess(),
	kit: {
		adapter: adapter(),
		experimental: {
			remoteFunctions: true
		}
	},
	compilerOptions: {
		experimental: {
			async: true
		}
	}
};

export default config;

Add your API key the same way as any server-only secret, no PUBLIC_ prefix so it never reaches the client bundle:

echo "RENDERFAST_API_KEY=your_api_key_here" >> .env

Write a server-only helper

Remote functions run on the server, but it’s still worth keeping the actual HTTP call in its own server-only module rather than inlining it. Create src/lib/server/renderfast.ts:

// src/lib/server/renderfast.ts
import { RENDERFAST_API_KEY } from '$env/static/private';

const RENDERFAST_ENDPOINT = 'https://yourdomain.com/api/generate';

type LayerOverrides = Record<string, { text?: string; image_url?: string }>;

export async function renderSocialImage(templateId: string, layers: LayerOverrides) {
	const response = await fetch(RENDERFAST_ENDPOINT, {
		method: 'POST',
		headers: {
			Authorization: `Bearer ${RENDERFAST_API_KEY}`,
			'Content-Type': 'application/json'
		},
		body: JSON.stringify({ templateId, layers, format: 'png' })
	});

	const result = await response.json();

	if (!response.ok) {
		throw new Error(result.error ?? `RenderFast returned ${response.status}`);
	}

	// { success, imageUrl, cached, warnings }
	return result as { success: boolean; imageUrl: string; cached: boolean; warnings?: unknown[] };
}

Anything under src/lib/server can only be imported from server-side code, so this file is a safe place for the API key to live even though remote functions themselves already guarantee server-only execution.

Write the remote function

Create src/lib/social-image.remote.ts. This is where command() from $app/server comes in, it validates its argument with a Valibot schema and runs the callback on the server whenever the client calls it:

// src/lib/social-image.remote.ts
import { command } from '$app/server';
import * as v from 'valibot';
import { renderSocialImage } from '$lib/server/renderfast';

const layerSchema = v.object({
	text: v.optional(v.string()),
	image_url: v.optional(v.string())
});

export const generateSocialImage = command(
	v.object({
		templateId: v.string(),
		layers: v.record(v.string(), layerSchema)
	}),
	async ({ templateId, layers }) => {
		return renderSocialImage(templateId, layers);
	}
);

command() is the right flavor here, not query(), because a render is a write from RenderFast’s point of view (it costs a generation and isn’t idempotent to cache), and commands are meant to be triggered by an explicit action rather than read during render. The exported generateSocialImage function is transformed into a typed client-callable wrapper automatically, there’s no separate client file to write.

Build the UI

Call the command straight from an event handler and store the result in local state:

<!-- src/routes/+page.svelte -->
<script lang="ts">
	import { generateSocialImage } from '$lib/social-image.remote';

	const TEMPLATE_ID = 'your-template-id';

	let title = $state('');
	let subtitle = $state('');
	let imageUrl = $state('');
	let generating = $state(false);
	let error = $state('');

	async function generate() {
		generating = true;
		error = '';

		try {
			const result = await generateSocialImage({
				templateId: TEMPLATE_ID,
				layers: {
					title: { text: title },
					subtitle: { text: subtitle }
				}
			});
			imageUrl = result.imageUrl;
		} catch (err) {
			error = err instanceof Error ? err.message : 'Something went wrong';
		} finally {
			generating = false;
		}
	}
</script>

<main class="mx-auto max-w-lg space-y-6 p-8">
	<h1 class="text-xl font-semibold">Social image generator</h1>

	<div class="space-y-3">
		<label class="block">
			<span class="text-sm font-medium">Title</span>
			<input class="w-full rounded border p-2" bind:value={title} placeholder="My Blog Post Title" />
		</label>

		<label class="block">
			<span class="text-sm font-medium">Subtitle</span>
			<input class="w-full rounded border p-2" bind:value={subtitle} placeholder="Published today" />
		</label>

		<button
			class="rounded bg-black px-4 py-2 text-white disabled:opacity-50"
			onclick={generate}
			disabled={generating}
		>
			{generating ? 'Generating…' : 'Generate image'}
		</button>
	</div>

	{#if error}
		<p class="text-sm text-red-600">{error}</p>
	{/if}

	{#if imageUrl}
		<div class="space-y-2">
			<img src={imageUrl} alt="Generated preview" class="w-full rounded border" />
			<a href={imageUrl} download class="text-sm underline">Download PNG</a>
		</div>
	{/if}
</main>

There’s no fetch, no /api/social-image route, and no manual JSON parsing on either side. generateSocialImage throws when the RenderFast call fails, which the try/catch above turns into the same error state the server-route version produced by checking response.ok. Because commands cannot run during render, the call has to happen inside an event handler like this one, not at the top of the component.

Test the workflow

npm run dev

Fill in the form and click Generate image. Open your browser’s network tab and you’ll see a POST request to a generated remote function endpoint rather than a route you wrote, that’s SvelteKit dispatching the call for you. The response shape is identical to the server-route version since both ultimately call the same /api/generate endpoint.

If validation fails, for example an empty templateId, the Valibot schema rejects the call before renderSocialImage ever runs, and the thrown error surfaces in your catch block just like a bad response would.

That validation step is the main thing you get for free here that the server-route version had to handle by hand. With a plain +server.ts route, nothing stops a caller from sending a malformed body until your code (or RenderFast’s API) rejects it. With command(), the schema is the contract, mismatched types or missing fields never reach renderSocialImage at all.

Where to go from here

Add a query() alongside this command if you want to load a saved list of your favorite templates on page load instead of hardcoding TEMPLATE_ID. Or add a second command for a different template size (a square version for other platforms) and let the user pick which one to render, the pattern stays the same either way: validate the input, call renderSocialImage, return the result.

For the full set of layer override fields you can pass for text, image, and shape layers, see the Generate API reference. When you’re ready to hook this up to a real account, create a RenderFast account and start from the quickstart guide.

Prefer the other approach?

If remote functions still feel experimental for your production app, or you’d rather stick with a plain API route you control end to end, the companion post builds the identical tool with a classic +server.ts route and fetch: Automate social images with SvelteKit server routes.