Serving your corpus from your own server
The recommended way to get documents into a customer-facing app — for anyone whose app has a server. Your server holds the credentials, fetches from S3 (or anywhere) once, and serves the files same-origin. The browser fetches a relative path. No credential ever reaches the client.
This is strictly better than a proxy for anyone who can use it: no public app key, no CORS negotiation with a bucket you do not control, and no egress on anybody else's bill.
Which shape are you?
| your app | corpus arrives by |
|---|---|
| Next · Nuxt · SvelteKit · SolidStart — has a server | this document |
| Vite SPA · webpack · a static host — no runtime server | end users upload (ingestFile), or the speedydb.org fetch proxy |
Four of the twelve shipped starters are server-rendered frameworks. If yours is one, use this.
SpeedyDb itself never runs on your server.
getServerSnapshot()returns one frozen, all-cold snapshot, and the SSR leg asserts a page rendersengine:cold semantic:off agent:off sources:0. The engine is wasm + OPFS/IndexedDB and hydrates in the browser. Your server is a file origin, not a SpeedyDb host — which is exactly why this works with no extra moving parts.
The pattern
1 · Pull at spin-up, on the server. Credentials stay in the server environment.
// app/lib/corpus.js — runs on the server only
import { S3Client, GetObjectCommand, ListObjectsV2Command } from "@aws-sdk/client-s3";
import { mkdir, writeFile } from "node:fs/promises";
const s3 = new S3Client({ region: process.env.AWS_REGION });
export async function syncCorpus(dir = "public/corpus") {
await mkdir(dir, { recursive: true });
const { Contents = [] } = await s3.send(
new ListObjectsV2Command({ Bucket: process.env.CORPUS_BUCKET, Prefix: "docs/" }),
);
for (const obj of Contents) {
const res = await s3.send(
new GetObjectCommand({ Bucket: process.env.CORPUS_BUCKET, Key: obj.Key }),
);
await writeFile(`${dir}/${obj.Key.split("/").pop()}`, Buffer.from(await res.Body.transformToByteArray()));
}
return Contents.map((o) => o.Key.split("/").pop());
}
Call it from wherever your framework runs startup work — a Next instrumentation.ts, a
SvelteKit hooks.server.js, a Nuxt nitro plugin, or a build step. Serving from public/ is the
simplest form; a route handler that streams from S3 per request works identically from the
browser's point of view, and is the better choice if the corpus is large or changes often.
2 · Publish the manifest. The browser needs to know what to fetch.
// app/api/corpus/route.js (Next app router)
import { readdir } from "node:fs/promises";
export async function GET() {
return Response.json({ files: await readdir("public/corpus") });
}
3 · Ingest same-origin, in the browser.
"use client";
import { useEffect } from "react";
import { useSpeedyDb } from "@speedydb/core/react";
export function CorpusLoader() {
const { ingestUrl, sources } = useSpeedyDb();
useEffect(() => {
if (sources.length) return; // already ingested — the store is durable
(async () => {
const { files } = await fetch("/api/corpus").then((r) => r.json());
for (const f of files) await ingestUrl(`/corpus/${f}`);
})();
}, [sources.length, ingestUrl]);
return null;
}
ingestUrl routes through ingestFile, so byte sniffing, the PDF/docx/odt extractors and the
upload cap all apply. (ingestSample also takes a URL but calls .text() — fine for the demo's
markdown, wrong for any binary format.)
Four things that will bite
Guard on sources.length, not on mount. The store is durable (OPFS/IndexedDB), so it survives
reload. Without the guard every visit re-fetches and re-ingests the whole corpus. Re-ingest is
safe — the keyed store heals rather than duplicating — but it is wasted bandwidth and a wasted
embedding pass on every page load.
Every end user ingests separately. The store is per-browser: ten users means ten ingests and ten embedding passes, all on their own machines. That is the architecture working as intended — nothing leaves the device — but it means the corpus you serve should be the working set, not an archive. A 2 GB bucket is not a thing to push through this.
Ship your saved ragConfig with it. The document is what makes the app behave the way it did
in the console:
<SpeedyDbProvider options={{ assetBase: "/speedydb/", ragConfig }}>
Its ingest block drives the first ingest in each end user's browser and is validated on
every store that already exists — chunk size and embedding space are frozen at ingest, and a
mismatched space wipes the store's vectors.
Serve the right content types. A .pdf served as text/plain still works — ingestFile
sniffs bytes and a PDF is a PDF whatever it is called — but a misconfigured static host that
returns text/html for a missing file will ingest the 404 page as a document. The !res.ok
check catches a real 404; it cannot catch a host that answers 200 with an error page.
Cold start
A fresh store answers nothing found until the first document lands, which is correct and reads
as broken. Render the ingest state — sources.length === 0 plus the pipeStage from the
snapshot — rather than a bare search box. The shipped components do this; if you build your own,
do not skip it.