Add Next.js app with Supabase auth, notes feature, and Docker setup

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
David Kiania 2026-04-16 15:43:09 +03:00
parent 57dab06b8e
commit fafef34304
21 changed files with 885 additions and 0 deletions

5
.env.example Normal file
View file

@ -0,0 +1,5 @@
# Copy this to .env.local for local dev
# In Dokploy, set these as environment variables directly
NEXT_PUBLIC_SUPABASE_URL=https://your-supabase.yourdomain.com
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key-here

28
.gitignore vendored Normal file
View file

@ -0,0 +1,28 @@
# deps
node_modules/
.pnp
.pnp.js
# build
.next/
out/
dist/
# env
.env
.env.local
.env.*.local
# misc
.DS_Store
*.pem
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

38
Dockerfile Normal file
View file

@ -0,0 +1,38 @@
# ── Stage 1: deps ──────────────────────────────────────────────────────────
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
# ── Stage 2: build ─────────────────────────────────────────────────────────
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Build args become env vars at build time for the Next.js public bundle
ARG NEXT_PUBLIC_SUPABASE_URL
ARG NEXT_PUBLIC_SUPABASE_ANON_KEY
ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL
ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY
RUN npm run build
# ── Stage 3: runner ────────────────────────────────────────────────────────
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

14
app/api/health/route.ts Normal file
View file

@ -0,0 +1,14 @@
import { NextResponse } from "next/server";
export const runtime = "edge";
export function GET() {
return NextResponse.json(
{
status: "ok",
app: "fireside-test-app",
ts: new Date().toISOString(),
},
{ status: 200 }
);
}

17
app/auth/login/page.tsx Normal file
View file

@ -0,0 +1,17 @@
import Link from "next/link";
import { AuthForm } from "@/components/AuthForm";
export default function LoginPage() {
return (
<div className="page">
<div className="card">
<h1 className="card-title">Sign in</h1>
<p className="card-sub">// fireside-test-app · auth smoke test</p>
<AuthForm mode="login" />
<Link href="/auth/signup" className="link">
No account? Sign up
</Link>
</div>
</div>
);
}

17
app/auth/signup/page.tsx Normal file
View file

@ -0,0 +1,17 @@
import Link from "next/link";
import { AuthForm } from "@/components/AuthForm";
export default function SignupPage() {
return (
<div className="page">
<div className="card">
<h1 className="card-title">Create account</h1>
<p className="card-sub">// fireside-test-app · new user registration</p>
<AuthForm mode="signup" />
<Link href="/auth/login" className="link">
Already have an account? Sign in
</Link>
</div>
</div>
);
}

49
app/dashboard/page.tsx Normal file
View file

@ -0,0 +1,49 @@
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
import { NotesList } from "@/components/NotesList";
import { SignOutButton } from "@/components/SignOutButton";
export default async function DashboardPage() {
const supabase = await createClient();
const { data: { user }, error: authError } = await supabase.auth.getUser();
if (authError || !user) redirect("/auth/login");
const { data: notes, error: notesError } = await supabase
.from("notes")
.select("id, body, created_at")
.eq("user_id", user.id)
.order("created_at", { ascending: false });
return (
<div className="dashboard">
<header className="topbar">
<div className="topbar-brand">
FIRESIDE <span>TEST</span>
</div>
<div className="topbar-user">
<span>{user.email}</span>
<SignOutButton />
</div>
</header>
<main className="main-content">
<div className="stack-badge">
<span className="badge active">Forgejo</span>
<span className="badge active">Dokploy</span>
<span className="badge active">Supabase Auth </span>
<span className="badge active">Supabase DB </span>
<span className="badge">Next.js 15 SSR</span>
</div>
<p className="section-label">// personal notes — user_id: {user.id.slice(0, 8)}…</p>
{notesError && (
<div className="error-msg">DB error: {notesError.message}</div>
)}
<NotesList initialNotes={notes ?? []} userId={user.id} />
</main>
</div>
);
}

306
app/globals.css Normal file
View file

@ -0,0 +1,306 @@
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=Syne:wght@400;600;700;800&display=swap');
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0d0d0d;
--surface: #161616;
--border: #2a2a2a;
--amber: #f59e0b;
--amber-dim: #92400e;
--text: #e8e8e8;
--muted: #666;
--danger: #ef4444;
--success: #22c55e;
--radius: 4px;
--mono: 'IBM Plex Mono', monospace;
--sans: 'Syne', sans-serif;
}
html, body { height: 100%; background: var(--bg); color: var(--text); font-family: var(--sans); }
/* ── Utilities ────────────────────────────────── */
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0,0,0,0); }
/* ── Page shell ───────────────────────────────── */
.page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
background:
radial-gradient(ellipse 60% 40% at 50% 0%, #1c1200 0%, transparent 70%),
var(--bg);
}
/* ── Card ─────────────────────────────────────── */
.card {
width: 100%;
max-width: 440px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 2.5rem;
}
.card-title {
font-size: 1.6rem;
font-weight: 800;
letter-spacing: -0.03em;
margin-bottom: 0.25rem;
}
.card-sub {
font-family: var(--mono);
font-size: 0.75rem;
color: var(--muted);
margin-bottom: 2rem;
}
/* ── Form ─────────────────────────────────────── */
.field { display: flex; flex-direction: column; gap: 0.4rem; margin-bottom: 1.25rem; }
label {
font-family: var(--mono);
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--muted);
}
input[type="email"],
input[type="password"],
input[type="text"],
textarea {
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
font-family: var(--mono);
font-size: 0.875rem;
padding: 0.65rem 0.9rem;
transition: border-color 0.15s;
width: 100%;
resize: vertical;
}
input:focus, textarea:focus {
outline: none;
border-color: var(--amber);
}
/* ── Buttons ──────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
font-family: var(--mono);
font-size: 0.8rem;
font-weight: 500;
letter-spacing: 0.05em;
padding: 0.7rem 1.25rem;
border-radius: var(--radius);
cursor: pointer;
border: none;
transition: opacity 0.15s, transform 0.1s;
}
.btn:active { transform: scale(0.98); }
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-primary {
background: var(--amber);
color: #000;
width: 100%;
}
.btn-primary:hover:not(:disabled) { opacity: 0.88; }
.btn-ghost {
background: transparent;
color: var(--muted);
border: 1px solid var(--border);
}
.btn-ghost:hover { color: var(--text); border-color: var(--text); }
.btn-danger {
background: transparent;
color: var(--danger);
border: 1px solid #3a1a1a;
font-size: 0.75rem;
padding: 0.35rem 0.75rem;
}
.btn-danger:hover { background: #1a0000; border-color: var(--danger); }
/* ── Feedback ─────────────────────────────────── */
.error-msg {
font-family: var(--mono);
font-size: 0.75rem;
color: var(--danger);
background: #1a0000;
border: 1px solid #3a0000;
border-radius: var(--radius);
padding: 0.6rem 0.9rem;
margin-bottom: 1rem;
}
.success-msg {
font-family: var(--mono);
font-size: 0.75rem;
color: var(--success);
background: #001a00;
border: 1px solid #003a00;
border-radius: var(--radius);
padding: 0.6rem 0.9rem;
margin-bottom: 1rem;
}
/* ── Link ─────────────────────────────────────── */
.link {
font-family: var(--mono);
font-size: 0.75rem;
color: var(--amber);
text-decoration: none;
text-align: center;
display: block;
margin-top: 1.25rem;
}
.link:hover { text-decoration: underline; }
/* ── Dashboard layout ─────────────────────────── */
.dashboard {
min-height: 100vh;
display: grid;
grid-template-rows: auto 1fr;
background: var(--bg);
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 2rem;
border-bottom: 1px solid var(--border);
background: var(--surface);
}
.topbar-brand {
font-size: 0.9rem;
font-weight: 700;
letter-spacing: 0.05em;
}
.topbar-brand span {
color: var(--amber);
}
.topbar-user {
font-family: var(--mono);
font-size: 0.72rem;
color: var(--muted);
display: flex;
align-items: center;
gap: 1rem;
}
.main-content {
max-width: 760px;
width: 100%;
margin: 0 auto;
padding: 2.5rem 2rem;
}
/* ── Notes ────────────────────────────────────── */
.section-label {
font-family: var(--mono);
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--amber-dim);
margin-bottom: 1rem;
}
.note-form {
display: flex;
gap: 0.75rem;
margin-bottom: 2rem;
align-items: flex-end;
}
.note-form textarea {
flex: 1;
min-height: 72px;
}
.note-form .btn-primary {
width: auto;
white-space: nowrap;
align-self: flex-end;
}
.notes-list { display: flex; flex-direction: column; gap: 0.75rem; }
.note-item {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1rem 1.1rem;
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
.note-body {
font-size: 0.875rem;
line-height: 1.6;
flex: 1;
word-break: break-word;
white-space: pre-wrap;
}
.note-time {
font-family: var(--mono);
font-size: 0.65rem;
color: var(--muted);
margin-top: 0.4rem;
}
.empty-state {
font-family: var(--mono);
font-size: 0.78rem;
color: var(--muted);
text-align: center;
padding: 3rem 0;
border: 1px dashed var(--border);
border-radius: var(--radius);
}
/* ── Stack badge ──────────────────────────────── */
.stack-badge {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin-bottom: 2rem;
}
.badge {
font-family: var(--mono);
font-size: 0.65rem;
padding: 0.25rem 0.6rem;
border-radius: 2px;
background: #1a1a1a;
border: 1px solid var(--border);
color: var(--muted);
}
.badge.active {
border-color: var(--amber-dim);
color: var(--amber);
}

19
app/layout.tsx Normal file
View file

@ -0,0 +1,19 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "Fireside Test App",
description: "End-to-end deployment test — Forgejo · Dokploy · Supabase",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}

10
app/page.tsx Normal file
View file

@ -0,0 +1,10 @@
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
export default async function Home() {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (user) redirect("/dashboard");
else redirect("/auth/login");
}

75
components/AuthForm.tsx Normal file
View file

@ -0,0 +1,75 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { createClient } from "@/lib/supabase/client";
export function AuthForm({ mode }: { mode: "login" | "signup" }) {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [info, setInfo] = useState("");
const [loading, setLoading] = useState(false);
async function handleSubmit() {
setError("");
setInfo("");
setLoading(true);
const supabase = createClient();
if (mode === "signup") {
const { error } = await supabase.auth.signUp({ email, password });
if (error) { setError(error.message); setLoading(false); return; }
setInfo("Account created — check your email to confirm, then sign in.");
setLoading(false);
return;
}
const { error } = await supabase.auth.signInWithPassword({ email, password });
if (error) { setError(error.message); setLoading(false); return; }
router.push("/dashboard");
router.refresh();
}
return (
<>
{error && <div className="error-msg">{error}</div>}
{info && <div className="success-msg">{info}</div>}
<div className="field">
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
placeholder="you@example.com"
autoComplete="email"
/>
</div>
<div className="field">
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
placeholder="••••••••"
autoComplete={mode === "login" ? "current-password" : "new-password"}
onKeyDown={e => e.key === "Enter" && handleSubmit()}
/>
</div>
<button
className="btn btn-primary"
onClick={handleSubmit}
disabled={loading || !email || password.length < 6}
>
{loading ? "Working…" : mode === "login" ? "Sign in" : "Create account"}
</button>
</>
);
}

96
components/NotesList.tsx Normal file
View file

@ -0,0 +1,96 @@
"use client";
import { useState } from "react";
import { createClient } from "@/lib/supabase/client";
type Note = { id: string; body: string; created_at: string };
export function NotesList({
initialNotes,
userId,
}: {
initialNotes: Note[];
userId: string;
}) {
const [notes, setNotes] = useState<Note[]>(initialNotes);
const [body, setBody] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const supabase = createClient();
async function addNote() {
if (!body.trim()) return;
setLoading(true);
setError("");
const { data, error } = await supabase
.from("notes")
.insert({ body: body.trim(), user_id: userId })
.select("id, body, created_at")
.single();
if (error) { setError(error.message); setLoading(false); return; }
setNotes([data, ...notes]);
setBody("");
setLoading(false);
}
async function deleteNote(id: string) {
const { error } = await supabase.from("notes").delete().eq("id", id);
if (error) { setError(error.message); return; }
setNotes(notes.filter(n => n.id !== id));
}
function fmt(ts: string) {
return new Date(ts).toLocaleString("en-KE", {
dateStyle: "medium",
timeStyle: "short",
});
}
return (
<>
{error && <div className="error-msg">{error}</div>}
<div className="note-form">
<textarea
value={body}
onChange={e => setBody(e.target.value)}
placeholder="Write a note…"
onKeyDown={e => e.key === "Enter" && e.metaKey && addNote()}
/>
<button
className="btn btn-primary"
onClick={addNote}
disabled={loading || !body.trim()}
>
{loading ? "Saving…" : "Add note"}
</button>
</div>
<div className="notes-list">
{notes.length === 0 ? (
<div className="empty-state">
No notes yet. Add one above to verify the DB write path.
</div>
) : (
notes.map(note => (
<div key={note.id} className="note-item">
<div>
<p className="note-body">{note.body}</p>
<p className="note-time">{fmt(note.created_at)}</p>
</div>
<button
className="btn btn-danger"
onClick={() => deleteNote(note.id)}
>
Delete
</button>
</div>
))
)}
</div>
</>
);
}

View file

@ -0,0 +1,21 @@
"use client";
import { useRouter } from "next/navigation";
import { createClient } from "@/lib/supabase/client";
export function SignOutButton() {
const router = useRouter();
async function signOut() {
const supabase = createClient();
await supabase.auth.signOut();
router.push("/auth/login");
router.refresh();
}
return (
<button className="btn btn-ghost" style={{ padding: "0.35rem 0.8rem", fontSize: "0.72rem" }} onClick={signOut}>
Sign out
</button>
);
}

24
docker-compose.yml Normal file
View file

@ -0,0 +1,24 @@
version: "3.9"
services:
app:
build:
context: .
dockerfile: Dockerfile
args:
NEXT_PUBLIC_SUPABASE_URL: ${NEXT_PUBLIC_SUPABASE_URL}
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${NEXT_PUBLIC_SUPABASE_ANON_KEY}
image: fireside-test-app:latest
restart: unless-stopped
ports:
- "3000:3000"
environment:
NODE_ENV: production
NEXT_PUBLIC_SUPABASE_URL: ${NEXT_PUBLIC_SUPABASE_URL}
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${NEXT_PUBLIC_SUPABASE_ANON_KEY}
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 20s

10
lib/supabase/client.ts Normal file
View file

@ -0,0 +1,10 @@
"use client";
import { createBrowserClient } from "@supabase/ssr";
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
}

27
lib/supabase/server.ts Normal file
View file

@ -0,0 +1,27 @@
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
export async function createClient() {
const cookieStore = await cookies();
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
);
} catch {
// Server Component — middleware handles session refresh
}
},
},
}
);
}

55
middleware.ts Normal file
View file

@ -0,0 +1,55 @@
import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";
export async function middleware(request: NextRequest) {
let supabaseResponse = NextResponse.next({ request });
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll();
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value }) =>
request.cookies.set(name, value)
);
supabaseResponse = NextResponse.next({ request });
cookiesToSet.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, options)
);
},
},
}
);
const {
data: { user },
} = await supabase.auth.getUser();
// Protect /dashboard routes
if (!user && request.nextUrl.pathname.startsWith("/dashboard")) {
const url = request.nextUrl.clone();
url.pathname = "/auth/login";
return NextResponse.redirect(url);
}
// Redirect logged-in users away from auth pages
if (
user &&
(request.nextUrl.pathname === "/auth/login" ||
request.nextUrl.pathname === "/auth/signup")
) {
const url = request.nextUrl.clone();
url.pathname = "/dashboard";
return NextResponse.redirect(url);
}
return supabaseResponse;
}
export const config = {
matcher: ["/dashboard/:path*", "/auth/:path*"],
};

7
next.config.ts Normal file
View file

@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
};
export default nextConfig;

24
package.json Normal file
View file

@ -0,0 +1,24 @@
{
"name": "fireside-test-app",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@supabase/ssr": "^0.5.2",
"@supabase/supabase-js": "^2.49.4",
"next": "15.3.1",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"typescript": "^5"
}
}

View file

@ -0,0 +1,23 @@
-- Run this in your Supabase SQL editor or via psql
create table if not exists public.notes (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users(id) on delete cascade,
body text not null check (char_length(body) > 0),
created_at timestamptz not null default now()
);
-- Row Level Security: users can only touch their own notes
alter table public.notes enable row level security;
create policy "Users can select own notes"
on public.notes for select
using (auth.uid() = user_id);
create policy "Users can insert own notes"
on public.notes for insert
with check (auth.uid() = user_id);
create policy "Users can delete own notes"
on public.notes for delete
using (auth.uid() = user_id);

20
tsconfig.json Normal file
View file

@ -0,0 +1,20 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}