Nuxt coursework boilerplate: Tailwind, Drizzle (MySQL), Redis, Pug page, Vercel deploy workflow

This commit is contained in:
2026-09-06 11:01:04 +02:00
commit 5920325d65
20 changed files with 2226 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
# Already set in your Coder workspace automatically - copy to .env only for
# running outside Coder (e.g. testing locally against your own MySQL/Redis).
MYSQL_HOST=school-mysql
MYSQL_PORT=3306
MYSQL_DATABASE=
MYSQL_USER=
MYSQL_PASSWORD=
REDIS_HOST=school-redis
REDIS_PORT=6379
REDIS_USERNAME=
REDIS_PASSWORD=
# Only needed if you want the deploy workflow to push this project to Vercel.
# Leave unset in Gitea Actions repo secrets and the deploy step is skipped.
VERCEL_TOKEN=
VERCEL_ORG_ID=
VERCEL_PROJECT_ID=
+25
View File
@@ -0,0 +1,25 @@
name: Deploy
on:
push:
branches: [main]
jobs:
deploy-vercel:
# Only runs once VERCEL_TOKEN is set as a repo secret - otherwise skipped.
if: ${{ secrets.VERCEL_TOKEN != '' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- name: Install dependencies
run: bun install
- name: Deploy to Vercel
run: bunx vercel deploy --prod --yes --token "$VERCEL_TOKEN"
env:
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
+24
View File
@@ -0,0 +1,24 @@
# Nuxt dev/build outputs
.output
.data
.nuxt
.nitro
.cache
dist
# Node dependencies
node_modules
# Logs
logs
*.log
# Misc
.DS_Store
.fleet
.idea
# Local env files
.env
.env.*
!.env.example
+43
View File
@@ -0,0 +1,43 @@
# Nuxt Boilerplate
Starter for the Nuxt coursework workspace: Bun + Nuxt + TypeScript + Tailwind,
with MySQL (Drizzle ORM) and Redis wired up to the shared course databases.
## Development
```bash
bun install
bun run db:push # creates the `notes` table from server/db/schema.ts
bun run dev
```
Open http://localhost:3000 - the home page shows a small notes list (MySQL)
and a key/value box (Redis) so you can see both connections working.
## What's here
- `app/pages/index.vue` - example page using `<template lang="pug">`, calling
the API routes below via `useFetch`/`$fetch`. Delete once you understand the
pattern and start building your own pages.
- `server/api/notes.get.ts` / `notes.post.ts` - example MySQL reads/writes via
Drizzle.
- `server/api/cache/[key].get.ts` / `[key].put.ts` - example Redis reads/writes.
- `server/db/schema.ts` - your Drizzle table definitions. Add tables here, then
run `bun run db:push` to sync them to MySQL (no migration files needed for
coursework - `push` just diffs your schema against the live database).
- `server/db/index.ts` / `server/utils/redis.ts` - pre-configured clients,
reading connection details from environment variables. In your Coder
workspace these are already set for you (`MYSQL_*`, `REDIS_*`) - you never
need to type a password into code.
- `.env.example` - only needed if you want to run this outside your Coder
workspace, against your own local MySQL/Redis.
- `.gitea/workflows/deploy.yml` - deploys to Vercel on push to `main`, but
only if you've set `VERCEL_TOKEN` (and `VERCEL_ORG_ID`/`VERCEL_PROJECT_ID`)
as repo secrets. Without them the job is skipped - nothing breaks.
## Notes
- Your Redis user can only touch keys under `student:<your-username>:*`
(enforced by the server) - that's why the cache routes prefix keys with
`REDIS_USERNAME`. Keys outside that prefix will be rejected.
- Your MySQL user only has access to your own database - no need to specify
it beyond what's already in `MYSQL_DATABASE`.
- Tailwind classes are available everywhere via `@nuxtjs/tailwindcss` - no
extra CSS file needed for basic usage.
+6
View File
@@ -0,0 +1,6 @@
<template>
<div>
<NuxtRouteAnnouncer />
<NuxtPage />
</div>
</template>
+55
View File
@@ -0,0 +1,55 @@
<script setup lang="ts">
const { data: notes, refresh } = await useFetch("/api/notes");
const title = ref("");
const body = ref("");
async function addNote() {
await $fetch("/api/notes", {
method: "POST",
body: { title: title.value, body: body.value },
});
title.value = "";
body.value = "";
await refresh();
}
const cacheKey = ref("greeting");
const cacheValue = ref("");
const cacheResult = ref("");
async function saveCacheValue() {
await $fetch(`/api/cache/${cacheKey.value}`, {
method: "PUT",
body: { value: cacheValue.value },
});
}
async function loadCacheValue() {
const res = await $fetch(`/api/cache/${cacheKey.value}`);
cacheResult.value = res.value ?? "(nothing cached yet)";
}
</script>
<template lang="pug">
main.mx-auto.max-w-xl.p-8.space-y-8
h1.text-2xl.font-bold Nuxt Boilerplate
section.space-y-2
h2.text-lg.font-semibold Notes (MySQL via Drizzle)
ul.list-disc.list-inside
li(v-for="note in notes" :key="note.id") {{ note.title }} - {{ note.body }}
form.flex.gap-2(@submit.prevent="addNote")
input.border.rounded.px-2.py-1(v-model="title" placeholder="Title")
input.border.rounded.px-2.py-1(v-model="body" placeholder="Body")
button.bg-blue-600.text-white.px-3.py-1.rounded(type="submit") Add
section.space-y-2
h2.text-lg.font-semibold Cache (Redis)
.flex.gap-2
input.border.rounded.px-2.py-1(v-model="cacheKey" placeholder="Key")
input.border.rounded.px-2.py-1(v-model="cacheValue" placeholder="Value")
button.bg-green-600.text-white.px-3.py-1.rounded(type="button" @click="saveCacheValue") Save
button.bg-gray-600.text-white.px-3.py-1.rounded(type="button" @click="loadCacheValue") Load
p {{ cacheResult }}
</template>
+1926
View File
File diff suppressed because it is too large Load Diff
+14
View File
@@ -0,0 +1,14 @@
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./server/db/schema.ts",
out: "./drizzle",
dialect: "mysql",
dbCredentials: {
host: process.env.MYSQL_HOST ?? "localhost",
port: Number(process.env.MYSQL_PORT ?? 3306),
database: process.env.MYSQL_DATABASE!,
user: process.env.MYSQL_USER,
password: process.env.MYSQL_PASSWORD,
},
});
+6
View File
@@ -0,0 +1,6 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
compatibilityDate: '2025-07-15',
devtools: { enabled: true },
modules: ['@nuxtjs/tailwindcss'],
})
+28
View File
@@ -0,0 +1,28 @@
{
"name": "nuxt-boilerplate",
"type": "module",
"private": true,
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio"
},
"dependencies": {
"@nuxtjs/tailwindcss": "^6.14.0",
"drizzle-orm": "^0.45.2",
"ioredis": "^6.0.0",
"mysql2": "^3.24.3",
"nuxt": "^4.5.2",
"pug": "^3.0.4",
"vue": "^3.5.42",
"vue-router": "^5.3.0"
},
"devDependencies": {
"@types/pug": "^2.0.10",
"drizzle-kit": "^0.31.10"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+2
View File
@@ -0,0 +1,2 @@
User-Agent: *
Disallow:
+7
View File
@@ -0,0 +1,7 @@
import { redis } from "~~/server/utils/redis";
export default defineEventHandler(async (event) => {
const key = getRouterParam(event, "key");
const value = await redis.get(`student:${process.env.REDIS_USERNAME}:${key}`);
return { key, value };
});
+8
View File
@@ -0,0 +1,8 @@
import { redis } from "~~/server/utils/redis";
export default defineEventHandler(async (event) => {
const key = getRouterParam(event, "key");
const body = await readBody<{ value: string }>(event);
await redis.set(`student:${process.env.REDIS_USERNAME}:${key}`, body.value);
return { ok: true };
});
+6
View File
@@ -0,0 +1,6 @@
import { db } from "~~/server/db";
import { notes } from "~~/server/db/schema";
export default defineEventHandler(async () => {
return db.select().from(notes);
});
+8
View File
@@ -0,0 +1,8 @@
import { db } from "~~/server/db";
import { notes } from "~~/server/db/schema";
export default defineEventHandler(async (event) => {
const body = await readBody<{ title: string; body: string }>(event);
await db.insert(notes).values({ title: body.title, body: body.body });
return { ok: true };
});
+14
View File
@@ -0,0 +1,14 @@
import { drizzle } from "drizzle-orm/mysql2";
import * as schema from "./schema";
export const db = drizzle({
connection: {
host: process.env.MYSQL_HOST ?? "localhost",
port: Number(process.env.MYSQL_PORT ?? 3306),
database: process.env.MYSQL_DATABASE,
user: process.env.MYSQL_USER,
password: process.env.MYSQL_PASSWORD,
},
schema,
mode: "default",
});
+8
View File
@@ -0,0 +1,8 @@
import { mysqlTable, int, varchar, timestamp } from "drizzle-orm/mysql-core";
export const notes = mysqlTable("notes", {
id: int("id").primaryKey().autoincrement(),
title: varchar("title", { length: 255 }).notNull(),
body: varchar("body", { length: 2000 }).notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
});
+10
View File
@@ -0,0 +1,10 @@
import { Redis } from "ioredis";
export const redis = new Redis({
host: process.env.REDIS_HOST ?? "localhost",
port: Number(process.env.REDIS_PORT ?? 6379),
username: process.env.REDIS_USERNAME,
password: process.env.REDIS_PASSWORD,
lazyConnect: true,
maxRetriesPerRequest: 2,
});
+18
View File
@@ -0,0 +1,18 @@
{
// https://nuxt.com/docs/guide/concepts/typescript
"files": [],
"references": [
{
"path": "./.nuxt/tsconfig.app.json"
},
{
"path": "./.nuxt/tsconfig.server.json"
},
{
"path": "./.nuxt/tsconfig.shared.json"
},
{
"path": "./.nuxt/tsconfig.node.json"
}
]
}