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 renders engine: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.