shovelbasedocs

Hello World: build the whole thing

One project that touches every pillar of the platform: a tiny public guestbook. Visitors sign up, post a “hello world” message with an optional photo, and see everyone else's. Along the way we use the database with RLS, storage, auth, an edge function, an MCP endpoint, a queue and trigger, a feature flag, signals, and a published static site — in that order, so each piece builds on the last. About 20 minutes, following along.

Before you start: the CLI and JavaScript SDK installed (see Installation), and shovelbase user login already run against your organization (see CLI reference). Node 18+ for the frontend tooling.

1. Create the project and scaffold the app

terminal
shovelbase projects create hello-world
shovelbase projects keys hello-world
# SHOVELBASE_URL=https://hello-world.shovelbase.com
# SHOVELBASE_ANON_KEY=eyJhbGciOi…
terminal
npm create vite@latest hello-world -- --template vanilla-ts
cd hello-world
npm install https://shovelbase.com/js/shovelbase-js-0.3.0.tgz
shovelbase link --project hello-world # writes shovelbase.json
.env
VITE_SHOVELBASE_URL=https://hello-world.shovelbase.com
VITE_SHOVELBASE_ANON_KEY=eyJhbGciOi

2. Database & storage: the guestbook table and a photos bucket

One migration creates the messages table with row-level security — anyone can read the guestbook, but you can only post as yourself — plus a public photos storage bucket with matching policies.

terminal
shovelbase migration create create_guestbook
shovelbase/migrations/20260706120000_create_guestbook.sql
create table public.messages (
id bigint generated always as identity primary key,
author_id uuid not null default auth.uid(),
body text not null,
photo_path text,
created_at timestamptz not null default now()
);
alter table public.messages enable row level security;
create policy "anyone can read the guestbook" on public.messages
for select to anon, authenticated using (true);
create policy "you can post your own messages" on public.messages
for insert to authenticated with check (author_id = auth.uid());
insert into storage.buckets (id, name, public) values ('photos', 'photos', true);
create policy "anyone can view photos" on storage.objects
for select to anon, authenticated using (bucket_id = 'photos');
create policy "you can upload your own photo" on storage.objects
for insert to authenticated with check (
bucket_id = 'photos' and (storage.foldername(name))[1] = auth.uid()::text
);
terminal
shovelbase db push

3. Function: moderate messages before they post

A plain edge function the client calls before inserting — no external API key needed, just enough to show the shape: read JSON in, return JSON out, deploy, call it from the SDK.

shovelbase/functions/moderate/index.ts
const BANNED = ['spam'];
Deno.serve(async (req: Request) => {
const { text } = await req.json();
const clean = !BANNED.some((word) => text.toLowerCase().includes(word));
return new Response(JSON.stringify({ clean }), {
headers: { 'content-type': 'application/json' },
});
});
terminal
shovelbase functions deploy moderate

4. The frontend: auth, database, storage, function, flag, signals

Everything visitors do goes through one shovelbase-js client. This is the whole app's worth of platform calls — swap in your own markup around it:

src/main.ts
import { createClient } from 'shovelbase-js';
const shovelbase = createClient(
import.meta.env.VITE_SHOVELBASE_URL,
import.meta.env.VITE_SHOVELBASE_ANON_KEY,
);
shovelbase.signals.track('page_view');
// --- auth ---
async function signUp(email: string, password: string) {
const { data, error } = await shovelbase.auth.signUp({ email, password });
if (data?.user) shovelbase.signals.identify(data.user.id);
return { data, error };
}
// --- post a message: moderate, upload photo, insert, flag, track ---
async function postMessage(text: string, photo?: File) {
const { data: mod } = await shovelbase.functions.invoke('moderate', { body: { text } });
if (!mod.clean) return alert('Message rejected');
const { data: { user } } = await shovelbase.auth.getUser();
let photo_path: string | undefined;
if (photo) {
photo_path = `${user!.id}/${crypto.randomUUID()}.png`;
await shovelbase.storage.from('photos').upload(photo_path, photo);
}
await shovelbase.from('messages').insert({ body: text, photo_path });
shovelbase.signals.track('message_posted', { has_photo: !!photo });
if (await shovelbase.flags.isEnabled('confetti')) launchConfetti();
}
// --- load the guestbook, newest first ---
async function loadMessages() {
const { data } = await shovelbase
.from('messages')
.select('body, photo_path, created_at')
.order('created_at', { ascending: false });
return data;
}

photo_path is namespaced under the user's id, matching what the migration's upload policy checks — (storage.foldername(name))[1]. Reading photos back is a public URL, no signing needed: shovelbase.storage.from('photos').getPublicUrl(photo_path).

5. Feature flag: gate the confetti

In the portal, go to Feature Flags and create confetti, off by default. The launchConfetti() call above only fires once you flip it on — ship the code dark, turn it on live, no redeploy.

6. MCP endpoint: let an AI client read the guestbook

Deploy a second function with --mcp exposing a single tool that queries messages with the service-role key, following the same TOOLS map pattern as the portal's own MCP template (see Functions):

shovelbase/functions/guestbook/index.ts
const TOOLS = {
list_messages: {
description: 'List the latest guestbook messages',
inputSchema: { type: 'object', properties: { limit: { type: 'number' } } },
handler: async ({ limit = 10 }: { limit?: number }) => {
const url = `${Deno.env.get('SHOVELBASE_URL')}/rest/v1/messages?select=body,created_at&order=created_at.desc&limit=${limit}`;
const res = await fetch(url, {
headers: { apikey: Deno.env.get('SHOVELBASE_SERVICE_ROLE_KEY')! },
});
return await res.json();
},
},
};
// … initialize / tools/list / tools/call JSON-RPC plumbing, same as the
// portal's "New MCP endpoint" template
terminal
shovelbase functions deploy guestbook --mcp
shovelbase mcp list
# guestbook auth=bearer-key https://hello-world.shovelbase.com/mcp/v1/guestbook
claude mcp add --transport http guestbook \
https://hello-world.shovelbase.com/mcp/v1/guestbook \
--header "Authorization: Bearer $ANON_KEY"

7. Queue & trigger: notify on every new message

A third function reacts to new messages via a queue, wired up the way Queues describes: a trigger runs the function for every message the queue receives. This is the mechanism a real notifier (email, Slack, …) would sit behind:

shovelbase/functions/notify/index.ts
Deno.serve(async (req: Request) => {
const body = await req.text();
console.log('new guestbook message:', body);
// … send yourself an email, ping Slack, whatever "notify" means for your app
return new Response('ok');
});
terminal
shovelbase functions deploy notify
shovelbase queues create new-messages
shovelbase triggers create notify-new --queue new-messages --function notify
shovelbase queues send new-messages '{"body": "hello world"}' # smoke test
shovelbase logs functions --lines 20 # notify's console.log lands here, ~10s later

8. Publish it

terminal
npm run build
shovelbase sites publish www --dir ./dist
# Publishing ./dist → site "www" (8 files)…
# Published: 8 uploaded, 0 unchanged, 0 removed
# https://hello-world.shovelbase.com/

Open that URL, sign up, post a “hello world” with a photo, and watch it appear — that's the whole stack, live. Optional next step: put it on your own domain (see Custom domains & HTTPS).

What you just built

FeatureWhere
Database + RLSmessages table, step 2
Storagephotos bucket + policies, step 2; upload/getPublicUrl, step 4
AuthsignUp/getUser, step 4
Functionsmoderate, step 3
Feature flagsconfetti, step 5
MCP endpointsguestbook, step 6
Queues & triggersnew-messages → notify, step 7
Signalspage_view / message_posted / identify, step 4
Static websiteswww, step 8

Everything above is also independently scriptable through the management API and inspectable from the portal — Table Editor, Storage, Functions, Queues, and Signals all show exactly what these steps just created.