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.tsfile exporting acommand()that validates its input with Valibot and calls RenderFast’s/api/generateendpoint 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.