Local Hackathon Scoreboard in Containers using Nuxt 4 + TypeScript + Docker

Build a self-hosted, real-time scoreboard for your local hackathon using the latest Nuxt 4 with TypeScript, Server-Sent Events (SSE) for live updates, and Docker for easy deployment with persistent storage.
Tags: Nuxt 4, TypeScript, Docker, Nitro, SSE
Time to read: 9 min
Why this stack (and what’s new)
- Nuxt 4 gives you typed runtime config, refined server routes, and upgraded Nitro for fast Node server builds—perfect for an all-in-one frontend + backend.
- Server-Sent Events (SSE) provide realtime updates without extra infra—great for LAN hackathons.
- Docker keeps it portable, with a data volume to persist scores between restarts.
We’ll ship a single Nuxt container that serves:
- A scoreboard UI
- JSON API for admin actions (add team, add/subtract points, reset)
- An SSE stream clients subscribe to for live updates
Prerequisites
- Node.js 20+ (Node 22 LTS recommended)
- Docker and Docker Compose
- Basic familiarity with Nuxt, TypeScript, and the terminal
1) Create the project
npx nuxi@latest init hackathon-scoreboard
cd hackathon-scoreboard
npm install
Add Zod for input validation:
npm i zod
Run dev to verify:
npm run dev
Visit http://localhost:3000
2) Configure Nuxt 4 for a Node server build
We’ll run Nuxt as a Node server inside Docker and persist scores to a JSON file under /data.
Create or update nuxt.config.ts:
// nuxt.config.ts
export default defineNuxtConfig({
ssr: true,
typescript: {
strict: true
},
nitro: {
preset: 'node-server' // ensure Node server output for Docker
},
runtimeConfig: {
// server-only
adminKey: process.env.NUXT_ADMIN_KEY || '',
dataDir: process.env.NUXT_DATA_DIR || './.data',
// public config if you need to expose something to client
public: {}
},
devtools: { enabled: true }
})
Create a .env for local dev:
echo "NUXT_ADMIN_KEY=changeme\nNUXT_DATA_DIR=.data" > .env
3) Server utilities: storage, auth, and SSE
We’ll store a scoreboard JSON file at runtimeConfig.dataDir, secure admin actions with a shared key via header, and broadcast updates via SSE.
Create server/utils/types.ts:
// server/utils/types.ts
export type Team = {
id: string
name: string
score: number
}
export type Scoreboard = {
teams: Team[]
updatedAt: string
}
Create server/utils/scoreboard.ts:
// server/utils/scoreboard.ts
import { promises as fsp } from 'node:fs'
import { existsSync } from 'node:fs'
import { join } from 'node:path'
import type { H3Event } from 'h3'
import type { Scoreboard } from './types'
async function ensureDir(dir: string) {
if (!existsSync(dir)) {
await fsp.mkdir(dir, { recursive: true })
}
}
function scoreboardPath(event: H3Event) {
const { dataDir } = useRuntimeConfig(event)
return join(dataDir, 'scoreboard.json')
}
export async function loadScoreboard(event: H3Event): Promise<Scoreboard> {
const { dataDir } = useRuntimeConfig(event)
await ensureDir(dataDir)
const file = scoreboardPath(event)
if (!existsSync(file)) {
const empty: Scoreboard = { teams: [], updatedAt: new Date().toISOString() }
await fsp.writeFile(file, JSON.stringify(empty, null, 2), 'utf-8')
return empty
}
const content = await fsp.readFile(file, 'utf-8')
try {
const parsed = JSON.parse(content) as Scoreboard
return parsed
} catch {
const empty: Scoreboard = { teams: [], updatedAt: new Date().toISOString() }
await fsp.writeFile(file, JSON.stringify(empty, null, 2), 'utf-8')
return empty
}
}
export async function saveScoreboard(event: H3Event, data: Scoreboard) {
const file = scoreboardPath(event)
data.updatedAt = new Date().toISOString()
await fsp.writeFile(file, JSON.stringify(data, null, 2), 'utf-8')
return data
}
Create server/utils/auth.ts:
// server/utils/auth.ts
import { createError, getRequestHeader } from 'h3'
import type { H3Event } from 'h3'
export function assertAdmin(event: H3Event) {
const adminKey = useRuntimeConfig(event).adminKey
if (!adminKey) {
throw createError({ statusCode: 500, statusMessage: 'Admin key not configured on server' })
}
const provided = getRequestHeader(event, 'x-admin-key')
if (!provided || provided !== adminKey) {
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' })
}
}
Create server/utils/sse.ts:
// server/utils/sse.ts
import type { H3Event } from 'h3'
import { setResponseHeader } from 'h3'
import type { ServerResponse } from 'node:http'
declare global {
// eslint-disable-next-line no-var
var sseClients: Set<ServerResponse> | undefined
}
const clients = (globalThis.sseClients ||= new Set<ServerResponse>())
export function registerSseClient(event: H3Event) {
const res = event.node.res
setResponseHeader(event, 'Content-Type', 'text/event-stream')
setResponseHeader(event, 'Cache-Control', 'no-cache')
setResponseHeader(event, 'Connection', 'keep-alive')
// Initial retry directive
res.write('retry: 10000\n\n')
// Keep connection
res.on('close', () => {
clients.delete(res)
})
clients.add(res)
}
export function broadcast(payload: any) {
const data = `data: ${JSON.stringify(payload)}\n\n`
for (const res of clients) {
try {
res.write(data)
} catch {
// ignore write failures; close will clean it up
}
}
}
4) API routes
We’ll add endpoints:
- GET /api/teams — list teams (sorted by score)
- POST /api/team — create team (admin)
- POST /api/score — add/subtract points (admin)
- POST /api/reset — wipe the board (admin)
- GET /api/stream — SSE for realtime updates
Create server/api/teams.get.ts:
// server/api/teams.get.ts
import { defineEventHandler } from 'h3'
import { loadScoreboard } from '~/server/utils/scoreboard'
export default defineEventHandler(async (event) => {
const sb = await loadScoreboard(event)
return sb.teams
.slice()
.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name))
})
Create server/api/team.post.ts:
// server/api/team.post.ts
import { defineEventHandler, readBody, createError } from 'h3'
import { z } from 'zod'
import { loadScoreboard, saveScoreboard } from '~/server/utils/scoreboard'
import { assertAdmin } from '~/server/utils/auth'
import { broadcast } from '~/server/utils/sse'
const BodySchema = z.object({
name: z.string().min(2).max(32)
})
export default defineEventHandler(async (event) => {
assertAdmin(event)
const parsed = BodySchema.safeParse(await readBody(event))
if (!parsed.success) {
throw createError({ statusCode: 400, statusMessage: parsed.error.message })
}
const { name } = parsed.data
const sb = await loadScoreboard(event)
if (sb.teams.some(t => t.name.toLowerCase() === name.toLowerCase())) {
throw createError({ statusCode: 409, statusMessage: 'Team with this name already exists' })
}
const id = crypto.randomUUID()
sb.teams.push({ id, name, score: 0 })
const saved = await saveScoreboard(event, sb)
broadcast({ type: 'update', teams: saved.teams, updatedAt: saved.updatedAt })
return { id }
})
Create server/api/score.post.ts:
// server/api/score.post.ts
import { defineEventHandler, readBody, createError } from 'h3'
import { z } from 'zod'
import { loadScoreboard, saveScoreboard } from '~/server/utils/scoreboard'
import { assertAdmin } from '~/server/utils/auth'
import { broadcast } from '~/server/utils/sse'
const BodySchema = z.object({
teamId: z.string().min(1),
delta: z.number().int().min(-100).max(100)
})
export default defineEventHandler(async (event) => {
assertAdmin(event)
const parsed = BodySchema.safeParse(await readBody(event))
if (!parsed.success) {
throw createError({ statusCode: 400, statusMessage: parsed.error.message })
}
const { teamId, delta } = parsed.data
const sb = await loadScoreboard(event)
const team = sb.teams.find(t => t.id === teamId)
if (!team) {
throw createError({ statusCode: 404, statusMessage: 'Team not found' })
}
team.score += delta
const saved = await saveScoreboard(event, sb)
broadcast({ type: 'update', teams: saved.teams, updatedAt: saved.updatedAt })
return { ok: true, score: team.score }
})
Create server/api/reset.post.ts:
// server/api/reset.post.ts
import { defineEventHandler } from 'h3'
import { assertAdmin } from '~/server/utils/auth'
import { saveScoreboard } from '~/server/utils/scoreboard'
import { broadcast } from '~/server/utils/sse'
export default defineEventHandler(async (event) => {
assertAdmin(event)
const saved = await saveScoreboard(event, { teams: [], updatedAt: new Date().toISOString() })
broadcast({ type: 'update', teams: saved.teams, updatedAt: saved.updatedAt })
return { ok: true }
})
Create server/api/stream.get.ts:
// server/api/stream.get.ts
import { defineEventHandler } from 'h3'
import { registerSseClient } from '~/server/utils/sse'
export default defineEventHandler(async (event) => {
registerSseClient(event)
// Keep the connection open by not returning any body
return event.node.res
})
5) Frontend page (scoreboard + admin controls)
Create pages/index.vue:
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
type Team = {
id: string
name: string
score: number
}
const adminKey = ref<string>('')
// persist admin key locally
onMounted(() => {
adminKey.value = localStorage.getItem('adminKey') || ''
})
watch(adminKey, (v) => {
localStorage.setItem('adminKey', v || '')
})
const { data: teams, refresh } = await useFetch<Team[]>('/api/teams', {
default: () => []
})
let es: EventSource | null = null
onMounted(() => {
es = new EventSource('/api/stream')
es.onmessage = (evt) => {
try {
const payload = JSON.parse(evt.data)
if (payload?.teams) {
teams.value = payload.teams as Team[]
}
} catch {}
}
})
onBeforeUnmount(() => {
es?.close()
})
const newTeamName = ref('')
async function addTeam() {
if (!newTeamName.value.trim()) return
await $fetch('/api/team', {
method: 'POST',
headers: { 'x-admin-key': adminKey.value },
body: { name: newTeamName.value.trim() }
})
newTeamName.value = ''
await refresh()
}
async function adjustScore(teamId: string, delta: number) {
await $fetch('/api/score', {
method: 'POST',
headers: { 'x-admin-key': adminKey.value },
body: { teamId, delta }
})
// SSE will update everyone; refresh is a fallback
}
async function resetBoard() {
if (!confirm('Reset the scoreboard?')) return
await $fetch('/api/reset', {
method: 'POST',
headers: { 'x-admin-key': adminKey.value }
})
}
</script>
<template>
<div class="container">
<h1>Hackathon Scoreboard</h1>
<p class="hint">Live updates via SSE. Open this page on a projector!</p>
<section class="board">
<table>
<thead>
<tr>
<th>#</th>
<th>Team</th>
<th>Score</th>
<th class="admin-col">Adjust</th>
</tr>
</thead>
<tbody>
<tr v-for="(t, i) in teams" :key="t.id">
<td>{{ i + 1 }}</td>
<td>{{ t.name }}</td>
<td class="score">{{ t.score }}</td>
<td class="admin-col">
<button @click="adjustScore(t.id, -10)">-10</button>
<button @click="adjustScore(t.id, -5)">-5</button>
<button @click="adjustScore(t.id, -1)">-1</button>
<button @click="adjustScore(t.id, 1)">+1</button>
<button @click="adjustScore(t.id, 5)">+5</button>
<button @click="adjustScore(t.id, 10)">+10</button>
</td>
</tr>
<tr v-if="!teams?.length">
<td colspan="4" class="empty">No teams yet. Add some below.</td>
</tr>
</tbody>
</table>
</section>
<section class="admin">
<h2>Admin</h2>
<label>
Admin Key:
<input v-model="adminKey" placeholder="enter key" type="password" />
</label>
<div class="admin-actions">
<input v-model="newTeamName" placeholder="Team name" />
<button @click="addTeam">Add Team</button>
<button class="danger" @click="resetBoard">Reset Board</button>
</div>
<p class="note">All admin actions send the key in the "x-admin-key" header.</p>
</section>
</div>
</template>
<style scoped>
.container {
max-width: 960px;
margin: 2rem auto;
padding: 0 1rem;
font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
}
h1 { margin-bottom: 0.25rem; }
.hint { color: #666; margin-bottom: 1.5rem; }
.board table {
width: 100%;
border-collapse: collapse;
margin-bottom: 2rem;
}
.board th, .board td {
border-bottom: 1px solid #eee;
padding: 0.75rem;
text-align: left;
}
.board th:nth-child(1), .board td:nth-child(1) {
width: 50px;
}
.board .score {
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.board .empty {
text-align: center;
color: #666;
}
.admin { border-top: 1px solid #eee; padding-top: 1rem; }
.admin input { margin-left: 0.5rem; }
.admin .admin-actions {
margin-top: 1rem;
display: flex;
gap: 0.5rem;
}
.admin .danger {
background: #ffefef;
border: 1px solid #ffaaaa;
}
.admin-col { white-space: nowrap; }
button {
padding: 0.35rem 0.6rem;
border: 1px solid #ddd;
border-radius: 6px;
background: #fff;
cursor: pointer;
}
button:hover { background: #f7f7f7; }
input {
padding: 0.35rem 0.5rem;
border: 1px solid #ddd;
border-radius: 6px;
}
.note { color: #666; margin-top: 0.5rem; }
</style>
6) Dockerize it
We’ll build a production Node server image and mount a volume at /data for persistence.
Create Dockerfile:
# Dockerfile
# --- Build stage ---
FROM node:22-alpine AS builder
WORKDIR /app
ENV NODE_ENV=production
# Install deps first (better cache)
COPY package.json package-lock.json* pnpm-lock.yaml* bun.lockb* yarn.lock* ./
# Use npm by default; switch to pnpm/yarn if you prefer
RUN npm ci
# Copy source
COPY . .
# Build Nuxt
RUN npm run build
# --- Runtime stage ---
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
# Create a directory for persistent data
RUN mkdir -p /data
ENV NUXT_DATA_DIR=/data
# Copy output
COPY --from=builder /app/.output ./.output
# Expose port
EXPOSE 3000
# The admin key is passed via env at runtime:
# ENV NUXT_ADMIN_KEY=changeme
CMD ["node", ".output/server/index.mjs"]
Create docker-compose.yml:
version: '3.9'
services:
scoreboard:
build: .
container_name: hackathon-scoreboard
ports:
- "3000:3000"
environment:
- NUXT_ADMIN_KEY=${NUXT_ADMIN_KEY:-changeme}
- NUXT_DATA_DIR=/data
volumes:
- scoreboard_data:/data
restart: unless-stopped
volumes:
scoreboard_data:
Build and run:
docker compose build
NUXT_ADMIN_KEY=supersecret docker compose up -d
Visit http://localhost:3000 and enter your admin key to add teams and adjust scores. Open the page on multiple devices—changes sync instantly via SSE.
7) Development vs production
- Local dev uses .env and the .data folder.
- In Docker, data is persisted in the scoreboard_data volume at /data.
- Admin endpoints require the x-admin-key header which the UI stores in localStorage.
8) Useful endpoints to test
- GET /api/teams
- POST /api/team with body { "name": "Team Alpha" }
- POST /api/score with body { "teamId": "
", "delta": 5 } - POST /api/reset
- GET /api/stream (opens an SSE stream in the browser/network tools)
Example curl:
curl -X POST http://localhost:3000/api/team \
-H 'Content-Type: application/json' \
-H 'x-admin-key: supersecret' \
-d '{"name":"Alpha"}'
9) Troubleshooting
- SSE not updating behind a reverse proxy? Ensure response buffering is disabled for the SSE path or proxy in TCP mode for local networks. For Nginx, set proxy_buffering off; for Caddy, use flush intervals.
- Permission errors writing to /data? Ensure the volume is writeable by the node user (alpine default is root). For stricter setups, chown the volume or run with a user.
- Admin key not set? Set NUXT_ADMIN_KEY as an environment variable. Without it, admin routes will 500 to avoid insecure default behavior.
10) Where to take this next
- Add QR share and projector mode
- Export final standings to CSV
- Add optimistic UI and undo last change
- Integrate Firebase Auth for admin
- Deploy behind Nginx with TLS and add a CDN for assets
Conclusion
You now have a containerized, real-time scoreboard built with Nuxt 4 + TypeScript, powered by Nitro and SSE, and ready to run anywhere Docker runs. Perfect for LAN hackathons, classrooms, or meetups—spin it up, share the admin key, and let the competition begin!





