Signup + login forms
Server actions + Zod.
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
'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
'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 tokensconst 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.
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:
export async function apiFetch(path: string, init?: RequestInit) {const access = (await cookies()).get('customer-access')?.valuelet 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')?.valueif (!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
Try it
Wire signup end-to-end on your scaffolded project:
- Add the form + server action.
- Visit
localhost:3000/signup. - 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