Node Vite React Express PostgreSQL watchlist
A watchlist app for planning movie and show nights, with a React interface over an Express JSON API. It runs on Tokay as one Web Service. Your list lives in a PostgreSQL database, so it survives every redeploy.
Last updated
The repository has a Vite client and an Express server, a common and performant web architecture. If you asked an AI to build you a website with a backend, you would probably get something close to this. Tokay recognizes the two halves and deploys them together, so you never wire them up yourself.
- Runtime
- Node.js
- Framework
- React, Vite, and Express
- Service types
- Web Service
- Resources
- PostgreSQL
- Required secrets
- None
What it does
The page shows a list of movies and shows that you can scroll, search by title, and filter by mood.
You can switch the view between waiting, watched, and all. A live count shows how many titles are waiting.
When you finish watching something, press Mark watched. Changed your mind? Put it back.
You can add your own titles with a format and a mood, and they appear in the list right away.
Deploy it
Bring this folder to Tokay any way you like. Paste or upload it in the dashboard, push it with git, or hand it to your AI agent along with our agent instructions. Tokay figures out the rest.
Working in Claude, ChatGPT, VS Code, or another MCP client? Connect the Tokay MCP server and ask your agent to deploy this example. It signs in through your browser, so there is no token to paste.
New to Tokay? The getting started guide walks you through your first deploy.
Finish setup in Tokay
The watchlist needs PostgreSQL to keep your recommendations. Open Environment, find DATABASE_URL under From your infrastructure, and choose Confirm database. Keep app, then choose Confirm.
When DATABASE_URL says Good to go, follow the deployment tracker. Tokay tests the database migration against a copy before release. If Go Live appears, choose it. When the Service status says Live, click the live URL in the top section to open the watchlist.
Try it
- Search for
moon, choose space, then switch between waiting and watched. - Add a recommendation of your own.
- Update the code as you like and deploy again. Your addition is still there because it was saved in your app's database.
How it works
The Express server serves both the React page and the JSON API from one address. When you mark a title watched, the page calls the API and the API updates a row in PostgreSQL. Refresh or redeploy and your list looks the same, because the data lives in the database instead of the app.
Wondering where the database comes from? Tokay creates it for you. It recognizes the migration file in this code, tests it on a copy of your database before touching the real one, and runs it during the deploy. The app then adds a few starting titles so your first visit is not an empty page.
Secrets
This example needs none. Tokay supplies DATABASE_URL when you confirm the managed PostgreSQL database.
Grow from here
Try the task tracker when you want a smaller interface and more API examples.
Source code
client/src/App.jsx Raw
import { useEffect, useMemo, useState } from 'react'
const tags = ['all', 'comfort', 'mystery', 'space', 'family', 'documentary']
export default function App() {
const [titles, setTitles] = useState([])
const [search, setSearch] = useState('')
const [tag, setTag] = useState('all')
const [view, setView] = useState('waiting')
const [loading, setLoading] = useState(true)
const [draft, setDraft] = useState({ title: '', format: 'movie', tag: 'comfort' })
const load = async () => {
const response = await fetch('/api/titles')
setTitles(await response.json())
setLoading(false)
}
useEffect(() => { load() }, [])
const visible = useMemo(() => titles.filter(item => {
const matchesSearch = item.title.toLowerCase().includes(search.toLowerCase())
const matchesTag = tag === 'all' || item.tag === tag
const matchesView = view === 'all' || (view === 'watched' ? item.watched : !item.watched)
return matchesSearch && matchesTag && matchesView
}), [titles, search, tag, view])
const toggle = async (item) => {
const response = await fetch(`/api/titles/${item.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ watched: !item.watched }),
})
const updated = await response.json()
setTitles(current => current.map(value => value.id === item.id ? updated : value))
}
const add = async (event) => {
event.preventDefault()
const response = await fetch('/api/titles', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(draft),
})
if (!response.ok) return
const created = await response.json()
setTitles(current => [created, ...current])
setDraft(current => ({ ...current, title: '' }))
setView('waiting')
}
return <main>
<header>
<div><p className="eyebrow">Lantern</p><h1>Good things to watch next.</h1></div>
<p className="summary"><strong>{titles.filter(item => !item.watched).length}</strong> waiting when the evening opens up.</p>
</header>
<section className="controls" aria-label="Watchlist controls">
<input value={search} onChange={event => setSearch(event.target.value)} placeholder="Search titles" aria-label="Search titles" />
<div className="segmented">
{['waiting', 'watched', 'all'].map(value => <button className={view === value ? 'active' : ''} key={value} onClick={() => setView(value)}>{value}</button>)}
</div>
</section>
<nav className="tags" aria-label="Filter by mood">
{tags.map(value => <button className={tag === value ? 'active' : ''} key={value} onClick={() => setTag(value)}>{value}</button>)}
</nav>
{loading ? <p className="empty">Loading your list...</p> : <section className="grid">
{visible.map((item, index) => <article className={`card tone-${(Number(item.id) + index) % 5}`} key={item.id}>
<div className="poster" aria-hidden="true"><span>{item.title.split(' ').filter(word => word.length > 2).slice(0, 2).map(word => word[0]).join('')}</span></div>
<div className="card-copy"><div className="meta"><span>{item.format}</span><span>{item.tag}</span></div><h2>{item.title}</h2><button className="watch" onClick={() => toggle(item)}>{item.watched ? 'Put it back' : 'Mark watched'}</button></div>
</article>)}
{!visible.length && <p className="empty">Nothing matches yet. Try another mood.</p>}
</section>}
<section className="add-panel">
<div><p className="eyebrow">Add one</p><h2>What did someone recommend?</h2></div>
<form onSubmit={add}>
<input required maxLength="120" value={draft.title} onChange={event => setDraft({ ...draft, title: event.target.value })} placeholder="Title" />
<select value={draft.format} onChange={event => setDraft({ ...draft, format: event.target.value })}><option value="movie">movie</option><option value="show">show</option></select>
<select value={draft.tag} onChange={event => setDraft({ ...draft, tag: event.target.value })}>{tags.slice(1).map(value => <option key={value}>{value}</option>)}</select>
<button>Add to watchlist</button>
</form>
</section>
</main>
}
client/src/main.jsx Raw
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.jsx'
import './style.css'
createRoot(document.getElementById('root')).render(
<StrictMode><App /></StrictMode>,
)
client/src/style.css Raw
:root{font-family:ui-sans-serif,system-ui,sans-serif;color:#f5f0e6;background:#171b28;font-synthesis:none}*{box-sizing:border-box}body{margin:0;min-width:320px;background:radial-gradient(circle at 80% -10%,#4e315c 0,transparent 35%),#171b28}button,input,select{font:inherit}button{cursor:pointer}main{max-width:1180px;margin:auto;padding:54px 26px 90px}header{display:flex;align-items:end;justify-content:space-between;gap:30px;margin-bottom:42px}.eyebrow{text-transform:uppercase;letter-spacing:.18em;font-size:.74rem;color:#f5bf76;font-weight:700;margin:0 0 10px}h1{font:500 clamp(3rem,8vw,6.5rem)/.88 Georgia,serif;max-width:760px;margin:0}.summary{max-width:210px;color:#c9c4cd;line-height:1.5}.summary strong{display:block;color:white;font:500 3rem Georgia,serif}.controls{display:flex;justify-content:space-between;gap:16px;margin:30px 0}.controls input,.add-panel input,.add-panel select{background:#252a3a;border:1px solid #3b4257;color:white;border-radius:10px;padding:12px 14px}.controls input{width:min(420px,100%)}.segmented,.tags{display:flex;gap:6px;flex-wrap:wrap}.segmented button,.tags button{border:1px solid #3b4257;background:transparent;color:#aaa9b3;border-radius:999px;padding:9px 13px}.segmented .active,.tags .active{background:#f5f0e6;color:#171b28;border-color:#f5f0e6}.tags{margin-bottom:25px}.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:16px}.card{background:#232838;border:1px solid #353b4f;border-radius:17px;overflow:hidden}.poster{height:190px;display:grid;place-items:center;background:linear-gradient(145deg,#c96d5c,#4e315c)}.tone-1 .poster{background:linear-gradient(145deg,#5f896f,#d7a761)}.tone-2 .poster{background:linear-gradient(145deg,#305c7b,#81709f)}.tone-3 .poster{background:linear-gradient(145deg,#bc784b,#d8b975)}.tone-4 .poster{background:linear-gradient(145deg,#6f4877,#b26972)}.poster span{font:500 4.4rem Georgia,serif;color:#fff9;letter-spacing:.06em}.card-copy{padding:18px}.meta{display:flex;gap:8px;text-transform:uppercase;letter-spacing:.11em;font-size:.66rem;color:#aaa9b3}.meta span+span:before{content:'·';margin-right:8px}.card h2{font:500 1.55rem/1.05 Georgia,serif;min-height:3.1rem}.watch{border:0;background:transparent;color:#f5bf76;padding:5px 0;font-weight:700}.empty{grid-column:1/-1;color:#aaa9b3}.add-panel{display:grid;grid-template-columns:1fr 2fr;gap:30px;align-items:end;border-top:1px solid #353b4f;margin-top:54px;padding-top:36px}.add-panel h2{font:500 2rem Georgia,serif;margin:0}.add-panel form{display:grid;grid-template-columns:2fr 1fr 1fr auto;gap:8px}.add-panel button{border:0;border-radius:10px;background:#f5bf76;color:#252033;padding:12px 16px;font-weight:700}@media(max-width:760px){header,.controls{display:block}.summary{margin-top:25px}.segmented{margin-top:12px}.add-panel{grid-template-columns:1fr}.add-panel form{grid-template-columns:1fr}}
client/index.html Raw
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="theme-color" content="#171b28">
<title>Lantern watchlist</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
client/package.json Raw
{
"name": "lantern-watchlist-client",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"build": "vite build"
},
"dependencies": {
"@vitejs/plugin-react": "^6.0.3",
"vite": "^8.1.5",
"react": "^19.2.7",
"react-dom": "^19.2.7"
},
"devDependencies": {}
}
client/vite.config.js Raw
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
})
migrations/committed/000001-watchlist.sql Raw
--! Previous: -
--! Hash: sha1:a0c827c911fae1b42c360359ef3184fe8e646f75
--! Message: watchlist
create table watchlist_titles (
id bigint generated always as identity primary key,
seed_key text unique,
title text not null check (char_length(title) between 1 and 120),
format text not null check (format in ('movie', 'show')),
tag text not null check (tag in ('comfort', 'mystery', 'space', 'family', 'documentary')),
watched boolean not null default false,
added_at timestamptz not null default now()
);
create index watchlist_titles_filter_idx on watchlist_titles (watched, tag, added_at desc);
server/index.js Raw
const path = require('path');
const express = require('express');
const { Pool } = require('pg');
const app = express();
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const port = Number(process.env.PORT || 3000);
const formats = new Set(['movie', 'show']);
const tags = new Set(['comfort', 'mystery', 'space', 'family', 'documentary']);
const seeds = [
['glass-orchard', 'The Glass Orchard', 'movie', 'mystery', false],
['night-bus', 'Night Bus to Bellwether', 'show', 'comfort', true],
['small-moons', 'A Field Guide to Small Moons', 'movie', 'space', false],
['soup-weather', 'Soup Weather', 'show', 'comfort', false],
['borrowed-light', 'Borrowed Light', 'movie', 'family', true],
['quiet-antenna', 'The Quiet Antenna', 'show', 'space', false],
['paper-kingdom', 'Paper Kingdom', 'movie', 'family', false],
['third-stair', 'The Third Stair', 'show', 'mystery', true],
['river-listens', 'When the River Listens', 'movie', 'documentary', false],
['kites', 'Kites Over Marrow Bay', 'movie', 'comfort', true],
['deep-catalog', 'The Deep Catalog', 'show', 'documentary', false],
['blue-hour', 'Blue Hour Detectives', 'show', 'mystery', false],
['sun-room', 'Everyone in the Sun Room', 'movie', 'family', false],
['tin-can', 'Tin Can Constellations', 'movie', 'space', true],
['last-seed', 'The Last Seed Library', 'show', 'documentary', false],
];
app.use(express.json({ limit: '32kb' }));
const toTitle = (row) => ({
id: String(row.id),
title: row.title,
format: row.format,
tag: row.tag,
watched: row.watched,
});
async function seed() {
for (const [seedKey, title, format, tag, watched] of seeds) {
await pool.query(
`insert into watchlist_titles (seed_key, title, format, tag, watched)
values ($1, $2, $3, $4, $5)
on conflict (seed_key) do nothing`,
[seedKey, title, format, tag, watched],
);
}
}
app.get('/health', async (_request, response) => {
await pool.query('select 1');
response.json({ ok: true });
});
app.get('/api/titles', async (request, response) => {
const values = [];
const conditions = [];
if (request.query.search) {
values.push(`%${String(request.query.search).slice(0, 80)}%`);
conditions.push(`title ilike $${values.length}`);
}
if (request.query.tag) {
if (!tags.has(request.query.tag)) return response.status(400).json({ error: 'Unknown tag.' });
values.push(request.query.tag);
conditions.push(`tag = $${values.length}`);
}
if (request.query.watched === 'true' || request.query.watched === 'false') {
values.push(request.query.watched === 'true');
conditions.push(`watched = $${values.length}`);
}
const where = conditions.length ? `where ${conditions.join(' and ')}` : '';
const result = await pool.query(
`select id, title, format, tag, watched from watchlist_titles ${where}
order by watched, added_at desc, id desc`,
values,
);
response.json(result.rows.map(toTitle));
});
app.post('/api/titles', async (request, response) => {
const title = String(request.body.title || '').trim();
const format = String(request.body.format || 'movie');
const tag = String(request.body.tag || 'comfort');
if (!title || title.length > 120) return response.status(400).json({ error: 'Title must be between 1 and 120 characters.' });
if (!formats.has(format)) return response.status(400).json({ error: 'Format must be movie or show.' });
if (!tags.has(tag)) return response.status(400).json({ error: 'Choose a known tag.' });
const result = await pool.query(
`insert into watchlist_titles (title, format, tag)
values ($1, $2, $3)
returning id, title, format, tag, watched`,
[title, format, tag],
);
response.status(201).json(toTitle(result.rows[0]));
});
app.patch('/api/titles/:id', async (request, response) => {
if (typeof request.body.watched !== 'boolean') return response.status(400).json({ error: 'watched must be true or false.' });
const result = await pool.query(
`update watchlist_titles set watched = $1 where id = $2
returning id, title, format, tag, watched`,
[request.body.watched, request.params.id],
);
if (!result.rowCount) return response.status(404).json({ error: 'Title not found.' });
response.json(toTitle(result.rows[0]));
});
const clientRoot = path.join(__dirname, '..', 'client', 'dist');
app.use(express.static(clientRoot));
app.use((request, response, next) => {
if (request.method !== 'GET') return next();
response.sendFile(path.join(clientRoot, 'index.html'));
});
seed()
.then(() => app.listen(port, '0.0.0.0', () => console.log(`Lantern watchlist listening on ${port}`)))
.catch((error) => {
console.error('The watchlist could not start.', error.message);
process.exit(1);
});
const shutdown = async () => {
await pool.end();
process.exit(0);
};
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
.gmrc Raw
{
"//generatedWith": "1.4.1"
}
package.json Raw
{
"name": "lantern-watchlist",
"version": "1.0.0",
"private": true,
"workspaces": [
"client"
],
"engines": {
"node": ">=24"
},
"scripts": {
"build": "npm run build --workspace client",
"start": "node server/index.js",
"migrate": "graphile-migrate migrate"
},
"dependencies": {
"express": "^5.2.1",
"graphile-migrate": "^1.4.1",
"pg": "^8.22.0"
}
}
Built from revision 52c7b7f