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
shovelbase projects create hello-worldshovelbase projects keys hello-world# SHOVELBASE_URL=https://hello-world.shovelbase.com# SHOVELBASE_ANON_KEY=eyJhbGciOi…npm create vite@latest hello-world -- --template vanilla-tscd hello-worldnpm install https://shovelbase.com/js/shovelbase-js-0.3.0.tgzshovelbase link --project hello-world # writes shovelbase.jsonVITE_SHOVELBASE_URL=https://hello-world.shovelbase.comVITE_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.
shovelbase migration create create_guestbookcreate 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 );shovelbase db push3. 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.
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' }, });});shovelbase functions deploy moderate4. 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:
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):
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" templateshovelbase functions deploy guestbook --mcpshovelbase 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:
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');});shovelbase functions deploy notifyshovelbase queues create new-messagesshovelbase triggers create notify-new --queue new-messages --function notify shovelbase queues send new-messages '{"body": "hello world"}' # smoke testshovelbase logs functions --lines 20 # notify's console.log lands here, ~10s later8. Publish it
npm run buildshovelbase 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
| Feature | Where |
|---|---|
| Database + RLS | messages table, step 2 |
| Storage | photos bucket + policies, step 2; upload/getPublicUrl, step 4 |
| Auth | signUp/getUser, step 4 |
| Functions | moderate, step 3 |
| Feature flags | confetti, step 5 |
| MCP endpoints | guestbook, step 6 |
| Queues & triggers | new-messages → notify, step 7 |
| Signals | page_view / message_posted / identify, step 4 |
| Static websites | www, 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.