I built a small application to keep track of the TV shows I’m watching.

Before that, I was keeping a list in a Word document, manually checking IMDb to find out whether programmes had been renewed or cancelled, and adding upcoming episodes to my calendar.

I eventually decided to automate it.

The applicationtv.joebailey.xyz favicon was built with Nuxt, using the Episodatewww.episodate.com favicon API to retrieve information about TV shows. It also needed somewhere to store the shows I was following, which episodes I’d watched and which user that information belonged to.

I’d originally used Fauna for the database, but decided to move the application over to Cloudflare D1.

I was already hosting the application on Cloudflare, so using its own database service seemed like a sensible option.

Cloudflare D1

Cloudflare D1developers.cloudflare.com favicon is a managed SQL database built on SQLite.

Unlike running a MySQL or PostgreSQL server, there’s no database server for me to maintain, and the database can be accessed directly from Cloudflare Workers.

That makes it particularly convenient for an application deployed using Nuxt’s Nitro Cloudflare preset.

The database is configured in wrangler.toml.

1[[d1_databases]]
2binding = "DB"
3database_name = "tv_shows"
4database_id = "<DATABASE_ID>"
5migrations_table = "migrations"
6migrations_dir = "drizzle"
Copied!

The important part is the binding name, DB.

Cloudflare makes that database available to the Worker at runtime, rather than requiring the application to connect using a conventional database hostname, username and password.

The application can then execute queries through the binding.

I used Drizzle ORMorm.drizzle.team favicon so I could work with the database using TypeScript instead of writing raw SQL for every operation.

Connecting Drizzle to D1

Drizzle has a D1 adapter, so connecting it to the database is fairly simple.

1import { drizzle } from 'drizzle-orm/d1'
2 
3export function useDb() {
4 return drizzle(__env__.DB)
5}
Copied!

In this project, __env__ contains the Cloudflare environment bindings made available through the Nitro runtime.

I also added TypeScript declarations for those bindings so that the application knows DB is a D1 database.

1import type { D1Database } from '@cloudflare/workers-types'
2 
3declare global {
4 const __env__: {
5 DB: D1Database
6 }
7}
Copied!

That means I get type checking when accessing Cloudflare-specific resources from the Nuxt server.

Defining the database schema

I used Drizzle to define the database tables in TypeScript.

The application has a few different types of data.

There’s the information about individual TV shows, which comes from Episodate. Then there’s user-specific information, such as which shows somebody is following and which episodes they’ve watched.

I wanted to avoid storing a separate copy of a show’s information for every user.

Instead, each TV show has one record containing its metadata, and another table links that show to the users following it.

A simplified version looks like this:

1import { sqliteTable, integer, text, uniqueIndex } from 'drizzle-orm/sqlite-core'
2 
3export const tvShows = sqliteTable('tv_shows', {
4 id: integer('id').primaryKey({ autoIncrement: true }),
5 showId: integer('showId').notNull(),
6 userId: text('userId').notNull()
7}, (table) => ({
8 showUserIdx: uniqueIndex('showUserIdx')
9 .on(table.showId, table.userId)
10}))
Copied!

The unique index prevents somebody adding the same show to their list more than once.

I used a similar approach for watched episodes.

Rather than updating a field on the episode itself, I have a table linking users to the episodes they’ve watched.

This means two users can follow the same programme without sharing their watch history.

Adding authentication with Auth.js

Once the application supported multiple users, I needed an authentication system.

I didn’t want to manage passwords myself, so I used Auth.jsauthjs.dev favicon, through the @hebilicious/authjs-nuxt module.

The application supports signing in through Google, GitHub and an email sign-in link.

The interesting part is that Auth.js also needs a database to store users, linked accounts, sessions and verification tokens.

Rather than introducing another storage service, I used D1 for that too.

Auth.js provides a Drizzle adapter which can handle the database operations.

1import { DrizzleAdapter } from '@auth/drizzle-adapter'
2import GoogleProvider from '@auth/core/providers/google'
3import GithubProvider from '@auth/core/providers/github'
4 
5export async function useAuthOptions() {
6 const db = useDb()
7 
8 return {
9 adapter: DrizzleAdapter(db),
10 providers: [
11 GoogleProvider({
12 clientId: process.env.GOOGLE_CLIENT_ID!,
13 clientSecret: process.env.GOOGLE_CLIENT_SECRET!
14 }),
15 GithubProvider({
16 clientId: process.env.GITHUB_CLIENT_ID!,
17 clientSecret: process.env.GITHUB_CLIENT_SECRET!
18 })
19 ]
20 }
21}
Copied!

This is a simplified example. My actual configuration also includes email authentication, custom sign-in page styling and additional Google permissions for calendar synchronisation.

The Auth.js adapter expects a particular set of tables, including users, accounts, sessions and verification tokens.

Those tables are defined alongside the rest of the application’s schema.

The Cloudflare context problem

One complication was that the authentication configuration needs access to D1.

That’s straightforward when a normal API request reaches a Cloudflare Worker, because the database binding is available in the request environment.

But Auth.js also needs to make requests to its own endpoints, including checking the current session.

I ran into problems where the database binding wasn’t available in the context that Auth.js was using.

The solution was to build the Auth.js configuration when handling the request, rather than creating it once when the application started.

1export default defineEventHandler(async (event) => {
2 const options = await useAuthOptions(event)
3 
4 const request = new Request(
5 getRequestURL(event),
6 {
7 headers: new Headers(getRequestHeaders(event)),
8 method: event.method
9 }
10 )
11 
12 return await Auth(request, options)
13})
Copied!

The real implementation also forwards POST request bodies and includes origin validation.

By constructing the options inside the request handler, I can create the Drizzle adapter using the database binding available to that request.

I also added a server-side helper to check the current session when accessing protected API routes.

Protecting user data

It isn’t enough to hide pages from people who aren’t logged in.

The backend needs to check which user is making the request before returning their data.

I created a helper which retrieves the current Auth.js session and returns the user’s email address.

If there’s no valid session, it rejects the request.

1export async function getAuthenticatedUserEmail(event) {
2 const session = await getServerSession(event)
3 
4 if (!session?.user?.email) {
5 throw createError({
6 statusCode: 403,
7 statusMessage: 'Unauthenticated'
8 })
9 }
10 
11 return session.user.email
12}
Copied!

The API can then use that identity to filter database queries.

For example, when fetching someone’s TV shows, it joins the list of followed shows to the users table and only returns records associated with the authenticated user.

The same checks apply when adding, removing or updating a show.

For a multi-user application, this is far more important than whether the frontend displays a login page.

Using D1 as a cache for external API data

The application gets information about TV programmes from the Episodate API.

I didn’t want to request the same information every time somebody opened the application, particularly because multiple users might be following the same programmes.

Instead, I store the data in D1.

When a programme is first added, the application checks whether it already has the show’s information.

If it doesn’t, it fetches the information from Episodate and saves it.

Subsequent requests can use the data already stored in D1.

I also added a scheduled task to refresh older records.

1export default defineTask({
2 meta: {
3 name: 'sync:episodate',
4 description: 'Sync shows from Episodate to D1'
5 },
6 
7 async run() {
8 const db = useDb()
9 
10 // Find shows that haven't been updated recently
11 // and refresh them from the external API.
12 }
13})
Copied!

The task runs every ten minutes and refreshes up to ten shows whose information is more than eight hours old.

That helps avoid making large numbers of requests to the external API at once.

It’s a fairly simple caching approach, but it works well for data that doesn’t need to be updated every second.

Database migrations

As the application grew, I added more tables and columns.

For example, I introduced support for watched episodes, watch partners and marking shows for rewatching.

Rather than changing the production database manually, I used Drizzle Kit to generate SQL migrations.

1pnpm drizzle-kit generate \
2 --schema=server/db/schema.ts \
3 --dialect=sqlite
Copied!

Those migrations are stored in the repository.

Cloudflare’s Wrangler CLI can then apply them to the local or remote D1 database.

1wrangler d1 migrations apply tv_shows --local
Copied!

And for the deployed database:

1wrangler d1 migrations apply tv_shows --remote
Copied!

Being able to keep the schema and migrations in Git makes changes easier to track and reproduce.

Was it worth it?

Moving to D1 meant I could keep the frontend, server-side API, database and scheduled tasks within the same hosting platform.

I didn’t need to maintain a separate database server, and Drizzle gave me a reasonably pleasant way of working with SQL from TypeScript.

Auth.js also meant I could add multiple authentication providers without building my own system for managing passwords and sessions.

There were a few complications, particularly around getting Auth.js to access Cloudflare’s database binding correctly.

But for a relatively small application, the combination of Nuxt, D1, Drizzle and Auth.js has been a useful way of building something with persistent data and user accounts without maintaining much infrastructure.

The source code is available on GitHubgithub.com favicon.