Signup + login forms

Server actions + Zod.

8 minmedium

Signup + login are the two most important forms in your app. This lesson covers the Grit pattern: server action for submit, Zod for validation, secure HTTP-only cookie for the session.

The signup form

apps/web/app/(auth)/signup/page.tsx
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { SignupSchema } from '@workspace/shared/schemas/auth'
import { signupAction } from './actions'
export default function SignupPage() {
const router = useRouter()
const [errors, setErrors] = useState<Record<string, string>>({})
async function onSubmit(form: FormData) {
const result = SignupSchema.safeParse(Object.fromEntries(form))
if (!result.success) {
setErrors(result.error.flatten().fieldErrors as any)
return
}
const res = await signupAction(result.data)
if (res?.error) {
setErrors({ form: res.error })
return
}
router.push('/dashboard')
}
return (
<form action={onSubmit} className="space-y-3 w-80">
<input name="email" placeholder="Email" />
{errors.email && <p className="text-red-500 text-sm">{errors.email}</p>}
<input name="password" type="password" placeholder="Password" />
{errors.password && <p className="text-red-500 text-sm">{errors.password}</p>}
<input name="name" placeholder="Your name" />
<button className="rounded-full bg-primary px-4 py-2 text-primary-foreground w-full">
Create account
</button>
{errors.form && <p className="text-red-500 text-sm">{errors.form}</p>}
</form>
)
}

The server action that hits your Go API

apps/web/app/(auth)/signup/actions.ts
'use server'
import { cookies } from 'next/headers'
import { z } from 'zod'
import { SignupSchema } from '@workspace/shared/schemas/auth'
export async function signupAction(input: z.infer<typeof SignupSchema>) {
const res = await fetch(`${process.env.API_URL}/api/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
})
const json = await res.json()
if (!res.ok) return { error: json.error?.message ?? 'Signup failed' }
// Set HTTP-only cookies with the tokens
const cookieStore = await cookies()
cookieStore.set('customer-access', json.data.access_token, {
httpOnly: true, secure: true, sameSite: 'lax', path: '/',
maxAge: 60 * 15, // 15 min
})
cookieStore.set('customer-refresh', json.data.refresh_token, {
httpOnly: true, secure: true, sameSite: 'lax', path: '/',
maxAge: 60 * 60 * 24 * 7, // 7 days
})
}

Server action runs in the Next.js server process, talks to your Go API, then sets HTTP-only cookies. JS in the browser never sees the token — XSS can't steal it.

HTTP-only cookies, not localStorage. JWTs in localStorage are readable by any JS on the page. An XSS bug = your whole user base's sessions stolen. HTTP-only cookies are unreachable from JS — XSS can't exfiltrate them.

Refresh on 401 — server-side

For server components / route handlers that fetch your Go API, wrap the fetch in an interceptor that refreshes when the access cookie is expired:

apps/web/lib/api.ts (server-side)
export async function apiFetch(path: string, init?: RequestInit) {
const access = (await cookies()).get('customer-access')?.value
let res = await fetch(`${process.env.API_URL}${path}`, {
...init,
headers: { ...init?.headers, Authorization: `Bearer ${access}` },
})
if (res.status === 401) {
const refresh = (await cookies()).get('customer-refresh')?.value
if (!refresh) redirect('/login')
const r = await fetch(`${process.env.API_URL}/api/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token: refresh }),
})
if (!r.ok) redirect('/login')
const json = await r.json()
;(await cookies()).set('customer-access', json.data.access_token, COOKIE_OPTS)
res = await fetch(`${process.env.API_URL}${path}`, {
...init,
headers: { ...init?.headers, Authorization: `Bearer ${json.data.access_token}` },
})
}
return res
}

Server components call apiFetch. Refresh is transparent; the user's session feels infinite.

The login form is the same, simpler

Same pattern, calls /api/auth/login instead of /api/auth/register. Same cookie setting. Same redirect on success.

Quick check

A teammate suggests storing the JWT in localStorage 'because it's simpler'. What's the strongest argument against?

Try it

Wire signup end-to-end on your scaffolded project:

  1. Add the form + server action.
  2. Visit localhost:3000/signup.
  3. Submit. Verify (a) the API created a user (check localhost:8080/studio), (b) cookies set in DevTools, (c) you land on /dashboard.

Paste the cookies (just names + types, not values) in notes.md.

What's next

User is signed up + signed in. Last lesson of this chapter — what do they see? Dashboard widgets.

Spot a typo? Have an idea?

Help us improve this lesson. One click opens a GitHub issue with the lesson URL pre-filled — suggest clearer wording, report a bug, or request more depth. The course keeps improving thanks to learners like you.

Suggest an improvement on GitHub