Node Vite React Express PostgreSQL watchlist

A React watchlist lets you plan movie and show nights through an Express JSON API backed by PostgreSQL. Tokay runs the frontend and API together as one Web Service.

Last updated

AI agent? Start with llms.txt.

The repository pairs a Vite client with an Express server, which is a common shape for AI generated React apps with a backend. Tokay detects both parts and deploys them together.

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, choose Mark watched. A watched title can be moved back to the planning list later.

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.

Database setup for the watchlist is complete after its Service connects to app. Tokay tests the database migration against a copy before release. Choose Go Live if prompted. When the Service is live, open the watchlist from its URL.

Try it

  1. Search for moon, choose space, then switch between waiting and watched.
  2. Add a recommendation of your own.
  3. 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.

Tokay creates the PostgreSQL database after you confirm the connection detected in the code. It rehearses the migration against a copy before production and runs it during deployment. 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.

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 2b22698

Guide: Deploy a React app with a backend