December 29, 2025 · 9 min

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

Build a real-time scoreboard for your local hackathon using Nuxt 4 (TypeScript), Server-Sent Events, and Docker with persistent storage.

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

nuxt-4-docker-scoreboard

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!