Skip to content
create-bsv-app

Tutorial: a signed guestbook

Build a real feature on top of your scaffold. Every guestbook entry is signed by a wallet and verified by your server, with no accounts or passwords.

You'll add one API route, one React page and one test script. By the end, your server will know exactly who wrote each entry, cryptographically, without storing a single password.

You'll learn how to:

  • protect an Express route with verifySignedRequest()
  • call it from React with signedFetch()
  • prove from the terminal that replayed and tampered requests are rejected

Allow about 15 minutes. The code on this page was compiled and run against create-bsv-app 1.1.2.

What you're building#

the flow
Browser                       Wallet                      Your server
───────                       ──────                      ───────────
type "gm", click Sign  ──▶  signs { action, body }
                            with your identity key  ──▶  POST /api/guestbook { proof, body }
                                                          verifySignedRequest()
                                                            ✓ signature valid for this body
                                                            ✓ nonce never seen before
                                                            ✓ proof not expired
◀───────────────────────────────────────────────────────  201 { identityKey, message }

The server never sees a password or a session cookie. It gets a signature it can check, and the identity key that made it.

1. Scaffold the app#

Skip this step if you already followed the quick start. Otherwise:

npx create-bsv-app@latest guestbook --starter full-stack --capabilities signed-requests --yes
cd guestbook

Run npm run dev and keep it running. The server restarts itself when you save, and the client hot-reloads.

2. Add the server route#

Create a new file next to the server entry. Anyone can read the guestbook. Writing needs a valid signed request.

// A tiny guestbook: anyone can read, only a wallet-signed request can write.
import type { interface Request<P = core.ParamsDictionary, ResBody = any, ReqBody = any, ReqQuery = QueryString.ParsedQs, Locals extends Record<string, any> = Record<string, any>>Request, interface Response<ResBody = any, Locals extends Record<string, any> = Record<string, any>>Response } from 'express'
import { 
function verifySignedRequest(serverWallet: {
    verifySignature: (args: any) => Promise<{
        valid: boolean;
    }>;
}, proof: AuthProof, opts: {
    action: string;
    body?: RequestBody;
}, consumeNonce: (nonce: string, expiresAt: Date) => boolean | Promise<boolean>): Promise<{
    valid: boolean;
    identityKey?: string;
    error?: string;
}>
verifySignedRequest
} from './bsv/verifySignedRequest.js'
import { function consumeNonce(nonce: string, expiresAt: Date): booleanconsumeNonce } from './bsv/nonceStore.js' type
type ServerWallet = {
    verifySignature: (args: any) => Promise<{
        valid: boolean;
    }>;
}
ServerWallet
= type Parameters<T extends (...args: any) => any> = T extends (...args: infer P) => any ? P : neverParameters<typeof
function verifySignedRequest(serverWallet: {
    verifySignature: (args: any) => Promise<{
        valid: boolean;
    }>;
}, proof: AuthProof, opts: {
    action: string;
    body?: RequestBody;
}, consumeNonce: (nonce: string, expiresAt: Date) => boolean | Promise<boolean>): Promise<{
    valid: boolean;
    identityKey?: string;
    error?: string;
}>
verifySignedRequest
>[0]
interface Entry { Entry.identityKey: stringidentityKey: string, Entry.message: stringmessage: string, Entry.at: stringat: string } const const entries: Entry[]entries: Entry[] = [] // in memory for now; swap for a database later export function function listEntries(_req: Request, res: Response): voidlistEntries (_req: Request<ParamsDictionary, any, any, QueryString.ParsedQs, Record<string, any>>_req: interface Request<P = core.ParamsDictionary, ResBody = any, ReqBody = any, ReqQuery = QueryString.ParsedQs, Locals extends Record<string, any> = Record<string, any>>Request, res: Response<any, Record<string, any>>res: interface Response<ResBody = any, Locals extends Record<string, any> = Record<string, any>>Response): void { res: Response<any, Record<string, any>>res.Response<any, Record<string, any>, number>.json: (body?: any) => Response<any, Record<string, any>>json(const entries: Entry[]entries.Array<Entry>.slice(start?: number, end?: number): Entry[]slice(-50).Array<Entry>.reverse(): Entry[]reverse()) } export function function signEntry(serverWallet: ServerWallet): (req: Request, res: Response) => Promise<void>signEntry (
serverWallet: {
    verifySignature: (args: any) => Promise<{
        valid: boolean;
    }>;
}
serverWallet
:
type ServerWallet = {
    verifySignature: (args: any) => Promise<{
        valid: boolean;
    }>;
}
ServerWallet
) {
return async (req: Request<ParamsDictionary, any, any, QueryString.ParsedQs, Record<string, any>>req: interface Request<P = core.ParamsDictionary, ResBody = any, ReqBody = any, ReqQuery = QueryString.ParsedQs, Locals extends Record<string, any> = Record<string, any>>Request, res: Response<any, Record<string, any>>res: interface Response<ResBody = any, Locals extends Record<string, any> = Record<string, any>>Response): interface Promise<T>Promise<void> => { const { const proof: anyproof, const body: anybody } = req: Request<ParamsDictionary, any, any, QueryString.ParsedQs, Record<string, any>>req.Request<ParamsDictionary, any, any, QueryString.ParsedQs, Record<string, any>>.body: anybody ?? {} const const message: anymessage = typeof const body: anybody?.message === 'string' ? const body: anybody.message.trim() : '' if (const message: anymessage.length === 0 || const message: anymessage.length > 280) { res: Response<any, Record<string, any>>res.Response<any, Record<string, any>, number>.status(code: number): Response<any, Record<string, any>>status(400).Response<any, Record<string, any>, number>.json: (body?: any) => Response<any, Record<string, any>>json({ error: stringerror: 'message must be 1–280 characters' }) return } // The proof is bound to this exact action + body. Change either and it fails. const
const result: {
    valid: boolean;
    identityKey?: string;
    error?: string;
}
result
= await
function verifySignedRequest(serverWallet: {
    verifySignature: (args: any) => Promise<{
        valid: boolean;
    }>;
}, proof: AuthProof, opts: {
    action: string;
    body?: RequestBody;
}, consumeNonce: (nonce: string, expiresAt: Date) => boolean | Promise<boolean>): Promise<{
    valid: boolean;
    identityKey?: string;
    error?: string;
}>
verifySignedRequest
(
serverWallet: {
    verifySignature: (args: any) => Promise<{
        valid: boolean;
    }>;
}
serverWallet
, const proof: anyproof, { action: stringaction: 'sign-guestbook', body?: RequestBody | undefinedbody }, function consumeNonce(nonce: string, expiresAt: Date): booleanconsumeNonce)
if (!
const result: {
    valid: boolean;
    identityKey?: string;
    error?: string;
}
result
.valid: booleanvalid ||
const result: {
    valid: boolean;
    identityKey?: string;
    error?: string;
}
result
.identityKey?: string | undefinedidentityKey == null) {
res: Response<any, Record<string, any>>res.Response<any, Record<string, any>, number>.status(code: number): Response<any, Record<string, any>>status(401).Response<any, Record<string, any>, number>.json: (body?: any) => Response<any, Record<string, any>>json({ error: stringerror: 'invalid proof' }) return } const
const entry: {
    identityKey: string;
    message: any;
    at: string;
}
entry
= { identityKey: stringidentityKey:
const result: {
    valid: boolean;
    identityKey?: string;
    error?: string;
}
result
.identityKey?: stringidentityKey, message: anymessage, at: stringat: new
var Date: DateConstructor
new () => Date (+3 overloads)
Date
().Date.toISOString(): stringtoISOString() }
const entries: Entry[]entries.Array<Entry>.push(...items: Entry[]): numberpush(
const entry: {
    identityKey: string;
    message: any;
    at: string;
}
entry
)
res: Response<any, Record<string, any>>res.Response<any, Record<string, any>, number>.status(code: number): Response<any, Record<string, any>>status(201).Response<any, Record<string, any>, number>.json: (body?: any) => Response<any, Record<string, any>>json(
const entry: {
    identityKey: string;
    message: any;
    at: string;
}
entry
)
} }

Then mount both handlers in server/src/index.ts, next to the existing /api/echo route:

server/src/index.ts
import { consumeNonce } from './bsv/nonceStore.js'
import { listEntries, signEntry } from './guestbook.js'

// …

app.post('/api/echo', async (req, res) => { /* … */ })
app.get('/api/guestbook', listEntries)
app.post('/api/guestbook', signEntry(serverWallet))
Deep diveWhy .js in a TypeScript import?

The server compiles to Node ES modules ("module": "NodeNext"), and Node needs the real file extension at runtime. TypeScript resolves ./guestbook.js to guestbook.ts while type-checking. The generated files follow the same rule.

3. Build the page#

useSignedRequest() gives you signedFetch(). It fetches the server's identity, asks the wallet to sign { action, body }, and posts { proof, body } through the bounded API client.

import { function useEffect(effect: React.EffectCallback, deps?: React.DependencyList): voiduseEffect, function useState<S>(initialState: S | (() => S)): [S, React.Dispatch<React.SetStateAction<S>>] (+1 overload)useState, type interface FormEvent<T = Element>FormEvent } from 'react'
import { const Link: React.ForwardRefExoticComponent<LinkProps & React.RefAttributes<HTMLAnchorElement>>Link } from 'react-router-dom'
import { function ConnectWallet(): React.JSX.ElementConnectWallet } from './bsv/ConnectWallet'
import { 
function useSignedRequest(serverIdentityKey?: string): {
    signedFetch: (url: string, opts: {
        action: string;
        body?: RequestBody;
    }) => Promise<Response>;
    connected: boolean;
}
useSignedRequest
} from './bsv/useSignedRequest'
import { function apiFetch(path: string, init?: RequestInit): Promise<Response>apiFetch, function readApiJson(response: Response): Promise<unknown>readApiJson } from './bsv/apiClient' interface Entry { Entry.identityKey: stringidentityKey: string, Entry.message: stringmessage: string, Entry.at: stringat: string } async function function fetchEntries(): Promise<Entry[]>fetchEntries (): interface Promise<T>Promise<Entry[]> { const const res: Responseres = await function apiFetch(path: string, init?: RequestInit): Promise<Response>apiFetch('/api/guestbook') return const res: Responseres.Response.ok: booleanok ? await function readApiJson(response: Response): Promise<unknown>readApiJson(const res: Responseres) as Entry[] : [] } export function function Guestbook(): React.JSX.ElementGuestbook () { const {
const signedFetch: (url: string, opts: {
    action: string;
    body?: RequestBody;
}) => Promise<Response>
signedFetch
, const connected: booleanconnected } =
function useSignedRequest(serverIdentityKey?: string): {
    signedFetch: (url: string, opts: {
        action: string;
        body?: RequestBody;
    }) => Promise<Response>;
    connected: boolean;
}
useSignedRequest
()
const [const entries: Entry[]entries, const setEntries: React.Dispatch<React.SetStateAction<Entry[]>>setEntries] = useState<Entry[]>(initialState: Entry[] | (() => Entry[])): [Entry[], React.Dispatch<React.SetStateAction<Entry[]>>] (+1 overload)useState<Entry[]>([]) const [const message: stringmessage, const setMessage: React.Dispatch<React.SetStateAction<string>>setMessage] = useState<string>(initialState: string | (() => string)): [string, React.Dispatch<React.SetStateAction<string>>] (+1 overload)useState('') const [const status: string | nullstatus, const setStatus: React.Dispatch<React.SetStateAction<string | null>>setStatus] = useState<string | null>(initialState: string | (() => string | null) | null): [string | null, React.Dispatch<React.SetStateAction<string | null>>] (+1 overload)useState<string | null>(null) function useEffect(effect: React.EffectCallback, deps?: React.DependencyList): voiduseEffect(() => { let let live: booleanlive = true function fetchEntries(): Promise<Entry[]>fetchEntries().Promise<Entry[]>.then<void, never>(onfulfilled?: ((value: Entry[]) => void | PromiseLike<void>) | null | undefined, onrejected?: ((reason: any) => PromiseLike<never>) | null | undefined): Promise<void>then((list: Entry[]list) => { if (let live: booleanlive) const setEntries: (value: React.SetStateAction<Entry[]>) => voidsetEntries(list: Entry[]list) }).Promise<void>.catch<void>(onrejected?: ((reason: any) => void | PromiseLike<void>) | null | undefined): Promise<void>catch(() => {}) return () => { let live: booleanlive = false } }, []) const const sign: (e: FormEvent) => Promise<void>sign = async (e: FormEvent<Element>e: interface FormEvent<T = Element>FormEvent) => { e: FormEvent<Element>e.React.BaseSyntheticEvent<Event, EventTarget & Element, EventTarget>.preventDefault(): voidpreventDefault() const setStatus: (value: React.SetStateAction<string | null>) => voidsetStatus('Approve the request in your wallet…') try { const const res: Responseres = await
const signedFetch: (url: string, opts: {
    action: string;
    body?: RequestBody;
}) => Promise<Response>
signedFetch
('/api/guestbook', { action: stringaction: 'sign-guestbook', body?: RequestBody | undefinedbody: { message: stringmessage } })
if (!const res: Responseres.Response.ok: booleanok) { const setStatus: (value: React.SetStateAction<string | null>) => voidsetStatus(`Server rejected it (${const res: Responseres.Response.status: numberstatus})`); return } const setMessage: (value: React.SetStateAction<string>) => voidsetMessage('') const setStatus: (value: React.SetStateAction<string | null>) => voidsetStatus(null) const setEntries: (value: React.SetStateAction<Entry[]>) => voidsetEntries(await function fetchEntries(): Promise<Entry[]>fetchEntries()) } catch (function (local var) err: unknownerr) { const setStatus: (value: React.SetStateAction<string | null>) => voidsetStatus(
var String: StringConstructor
(value?: any) => string
String
(function (local var) err: unknownerr))
} } return ( <React.JSX.IntrinsicElements.main: React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement>main React.HTMLAttributes<HTMLElement>.className?: string | undefinedclassName="bsv-page"> <const Link: React.ForwardRefExoticComponent<LinkProps & React.RefAttributes<HTMLAnchorElement>>Link className?: string | undefinedclassName="bsv-back" LinkProps.to: Toto="/">← Back to home</const Link: React.ForwardRefExoticComponent<LinkProps & React.RefAttributes<HTMLAnchorElement>>Link> <React.JSX.IntrinsicElements.h1: React.DetailedHTMLProps<React.HTMLAttributes<HTMLHeadingElement>, HTMLHeadingElement>h1>Guestbook</React.JSX.IntrinsicElements.h1: React.DetailedHTMLProps<React.HTMLAttributes<HTMLHeadingElement>, HTMLHeadingElement>h1> <React.JSX.IntrinsicElements.p: React.DetailedHTMLProps<React.HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>p>Sign with your wallet. No account, no password.</React.JSX.IntrinsicElements.p: React.DetailedHTMLProps<React.HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>p> <function ConnectWallet(): React.JSX.ElementConnectWallet /> {const connected: booleanconnected && ( <React.JSX.IntrinsicElements.form: React.DetailedHTMLProps<React.FormHTMLAttributes<HTMLFormElement>, HTMLFormElement>form React.DOMAttributes<HTMLFormElement>.onSubmit?: React.SubmitEventHandler<HTMLFormElement> | undefinedonSubmit={(e: React.SubmitEvent<HTMLFormElement>e) => { void const sign: (e: FormEvent) => Promise<void>sign(e: React.SubmitEvent<HTMLFormElement>e) }}> <React.JSX.IntrinsicElements.input: React.DetailedHTMLProps<React.InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>input React.InputHTMLAttributes<HTMLInputElement>.value?: string | number | readonly string[] | undefinedvalue={const message: stringmessage} React.InputHTMLAttributes<HTMLInputElement>.onChange?: React.ChangeEventHandler<HTMLInputElement, HTMLInputElement> | undefinedonChange={(e: React.ChangeEvent<HTMLInputElement, HTMLInputElement>e) => const setMessage: (value: React.SetStateAction<string>) => voidsetMessage(e: React.ChangeEvent<HTMLInputElement, HTMLInputElement>e.React.ChangeEvent<HTMLInputElement, HTMLInputElement>.target: EventTarget & HTMLInputElementtarget.HTMLInputElement.value: stringvalue)} React.InputHTMLAttributes<HTMLInputElement>.maxLength?: number | undefinedmaxLength={280} React.InputHTMLAttributes<HTMLInputElement>.placeholder?: string | undefinedplaceholder="Say gm" /> <React.JSX.IntrinsicElements.button: React.DetailedHTMLProps<React.ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>button React.HTMLAttributes<T>.className?: string | undefinedclassName="bsv-btn" React.ButtonHTMLAttributes<HTMLButtonElement>.disabled?: boolean | undefineddisabled={const message: stringmessage.String.trim(): stringtrim() === ''}>Sign guestbook</React.JSX.IntrinsicElements.button: React.DetailedHTMLProps<React.ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>button> </React.JSX.IntrinsicElements.form: React.DetailedHTMLProps<React.FormHTMLAttributes<HTMLFormElement>, HTMLFormElement>form> )} {const status: string | nullstatus != null && <React.JSX.IntrinsicElements.p: React.DetailedHTMLProps<React.HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>p>{const status: stringstatus}</React.JSX.IntrinsicElements.p: React.DetailedHTMLProps<React.HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>p>} <React.JSX.IntrinsicElements.ul: React.DetailedHTMLProps<React.HTMLAttributes<HTMLUListElement>, HTMLUListElement>ul> {const entries: Entry[]entries.Array<Entry>.map<React.JSX.Element>(callbackfn: (value: Entry, index: number, array: Entry[]) => React.JSX.Element, thisArg?: any): React.JSX.Element[]map((entry: Entryentry) => ( <React.JSX.IntrinsicElements.li: React.DetailedHTMLProps<React.LiHTMLAttributes<HTMLLIElement>, HTMLLIElement>li React.Attributes.key?: React.Key | null | undefinedkey={entry: Entryentry.Entry.at: stringat + entry: Entryentry.Entry.identityKey: stringidentityKey}> <React.JSX.IntrinsicElements.code: React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement>code>{entry: Entryentry.Entry.identityKey: stringidentityKey.String.slice(start?: number, end?: number): stringslice(0, 10)}…</React.JSX.IntrinsicElements.code: React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement>code> {entry: Entryentry.Entry.message: stringmessage} </React.JSX.IntrinsicElements.li: React.DetailedHTMLProps<React.LiHTMLAttributes<HTMLLIElement>, HTMLLIElement>li> ))} </React.JSX.IntrinsicElements.ul: React.DetailedHTMLProps<React.HTMLAttributes<HTMLUListElement>, HTMLUListElement>ul> </React.JSX.IntrinsicElements.main: React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement>main> ) }

Register the route and link to it from the home hub:

import { SignedRequestDemo } from './bsv/SignedRequestDemo'
import { Guestbook } from './Guestbook'

// …inside <Routes>
        <Route path="/signed-demo" element={<SignedRequestDemo />} />
        <Route path="/guestbook" element={<Guestbook />} />

4. Try it#

Open http://localhost:5173, connect your wallet, then click Guestbook →. Type a message and press Sign guestbook. Your wallet asks you to approve, and then your entry appears with the first characters of your identity key.

Open a second browser profile with a different wallet and sign again. Two keys, two authors, and no user table anywhere.

5. Break it on purpose#

A guestbook is only as good as its forgery protection, so let's attack it. This script signs entries with a brand-new throwaway key, then tries a replay (sending the same proof twice) and a tamper (a valid proof with a different body):

server/scripts/try-guestbook.ts
// Sign guestbook entries from Node with a throwaway key, then try to cheat.
import { class PrivateKeyPrivateKey, class ProtoWalletProtoWallet } from '@bsv/sdk'
import { 
function createAuthProof(wallet: ProofSignerWallet, opts: {
    counterparty: string;
    action: string;
    body?: RequestBody;
}): Promise<AuthProof>
createAuthProof
} from '../src/bsv/auth.js'
const const API: "http://localhost:3000"API = 'http://localhost:3000' const { identityKey: const server: anyserver } = await (await function fetch(input: string | URL | Request, init?: RequestInit): Promise<Response> (+1 overload)fetch(`${const API: "http://localhost:3000"API}/api/identity`)).Body.json(): Promise<any>json() const const wallet: ProtoWalletwallet = new new ProtoWallet(rootKeyOrKeyDeriver?: PrivateKey | "anyone" | KeyDeriverApi): ProtoWalletProtoWallet(class PrivateKeyPrivateKey.PrivateKey.fromRandom(): PrivateKeyfromRandom()) // a brand-new identity async function function post(label: string, payload: unknown): Promise<void>post (label: stringlabel: string, payload: unknownpayload: unknown) { const const res: Responseres = await function fetch(input: string | URL | Request, init?: RequestInit): Promise<Response> (+1 overload)fetch(`${const API: "http://localhost:3000"API}/api/guestbook`, { RequestInit.method?: string | undefinedmethod: 'POST', RequestInit.headers?: HeadersInit | undefinedheaders: { 'content-type': 'application/json' }, RequestInit.body?: BodyInit | null | undefinedbody: var JSON: JSONJSON.JSON.stringify(value: any, replacer?: (this: any, key: string, value: any) => any, space?: string | number): string (+1 overload)stringify(payload: unknownpayload) }) var console: Consoleconsole.Console.log(message?: any, ...optionalParams: any[]): void (+1 overload)log(label: stringlabel.String.padEnd(maxLength: number, fillString?: string): stringpadEnd(10), const res: Responseres.Response.status: numberstatus, await const res: Responseres.Body.text(): Promise<string>text()) } const
const body: {
    message: string;
}
body
= { message: stringmessage: 'gm from Node' }
const const proof: AuthProofproof = await
function createAuthProof(wallet: ProofSignerWallet, opts: {
    counterparty: string;
    action: string;
    body?: RequestBody;
}): Promise<AuthProof>
createAuthProof
(const wallet: ProtoWalletwallet, { counterparty: stringcounterparty: const server: anyserver, action: stringaction: 'sign-guestbook', body?: RequestBody | undefinedbody })
await function post(label: string, payload: unknown): Promise<void>post('signed', { proof: AuthProofproof,
body: {
    message: string;
}
body
})
await function post(label: string, payload: unknown): Promise<void>post('replayed', { proof: AuthProofproof,
body: {
    message: string;
}
body
})
await function post(label: string, payload: unknown): Promise<void>post('tampered', { proof: AuthProofproof: await
function createAuthProof(wallet: ProofSignerWallet, opts: {
    counterparty: string;
    action: string;
    body?: RequestBody;
}): Promise<AuthProof>
createAuthProof
(const wallet: ProtoWalletwallet, { counterparty: stringcounterparty: const server: anyserver, action: stringaction: 'sign-guestbook', body?: RequestBody | undefinedbody }),
body: {
    message: string;
}
body
: { message: stringmessage: 'gm from Mallory' } })

Run it from the server folder while npm run dev is still going. You should see:

Terminal
cd server
npx tsx scripts/try-guestbook.ts
signed     201 {"identityKey":"020d26…","message":"gm from Node","at":"…"}
replayed   401 {"error":"invalid proof"}
tampered   401 {"error":"invalid proof"}

The replay fails because the server already consumed that proof's nonce. The tamper fails because the signature covers the exact body, and gm from Mallory isn't what was signed.

Deep diveWhat exactly is in a proof?

The wallet signs { action, identityKey, expiresAt, nonce } with the body's exact JSON bytes appended, using a key derived between your identity key and the server's identity key (that's why the client fetches GET /api/identity first). The server checks the signature, checks that expiresAt hasn't passed (proofs live for 2 minutes by default), and records the nonce so it can never be used again. See Security model for the full list of guarantees.

Recap#

You built a feature where:

  • identity comes from the wallet. result.identityKey is the only "user id" you need.
  • every write is authenticated on its own. No session to steal, no cookie to fixate.
  • forgery fails closed. Replays, tampering and expired proofs all get 401.

Where to take it next#

  • Persist it. Swap the entries array for a database, keyed by identityKey.
  • Harden replay protection. The generated nonceStore.ts is in-memory and single-process. Before you run more than one server instance, back it with Redis or your database. Here's how.
  • Add sessions if you want them. Use wallet login once, then issue your own session or JWT.
  • Ship it. Deploy the client and server, with the three environment variables production needs.