← VolcanoCONTENT HISTORY

Update to Volcano

Snapshot Oct 24, 2026 · 13:36 UTC · version 0.2.26

Collection source: downloaded plugin package.

WHAT CHANGED

First saved snapshot

No earlier snapshot is available to establish a change.

Save Volcano and check future changes in Following.

Up to 10 plugins free · no card required.

Compare saved observations

Download comparison JSON
Full technical diff · 0 changed fields
Full snapshot data
{
  "description": "Use for Next.js on Volcano and for web apps, pages, dashboards, boards, galleries, full-stack UIs, public HTTP routes, redirects, webhook ingress, middleware, API routes, server actions, and client or server SDK separation.",
  "included_files": [],
  "name": "volcano-nextjs",
  "skill_md_contents": "---\nname: volcano-nextjs\ndescription: Use for Next.js on Volcano and for web apps, pages, dashboards, boards, galleries, full-stack UIs, public HTTP routes, redirects, webhook ingress, middleware, API routes, server actions, and client or server SDK separation.\n---\n# Volcano Next.js Skill\n\n## Role\nImplement Volcano SDK in Next.js with strict client/server separation and middleware-first auth checks. This skill is self-contained: env contract, shared client pattern, AuthContext template, middleware helpers, app/pages router examples, server actions, OAuth callback, and SSR safety are embedded.\n\n## Workflow\n1. Centralize the Volcano client behind a `getVolcano()` helper used by all client components.\n2. Use middleware helpers (`createServerClient`, `withAuth`, `getTokenFromRequest`) for server/middleware auth checks.\n3. Keep browser-only auth actions (OAuth initiation, localStorage session) out of server-only code.\n4. Validate redirect/protection behavior and hydration edge cases.\n\n## UI/UX Pairing\nRead `volcano-uiux` for every user-facing Next.js interface. It owns accessibility, responsive layout, loading and progress feedback, forms, navigation, and interface-state guidance. This skill owns the Next.js implementation and client/server boundary.\n\n## Environment Contract\n`.env.local`:\n```env\nNEXT_PUBLIC_VOLCANO_API_URL=https://api.yourproject.volcano.dev\nNEXT_PUBLIC_VOLCANO_ANON_KEY=ak-your-anon-key\nNEXT_PUBLIC_VOLCANO_DATABASE_NAME=your-database\n```\n- `NEXT_PUBLIC_*` vars are exposed to both client and server.\n- Server-only secrets MUST NOT have the `NEXT_PUBLIC_` prefix.\n\n## Execution Environments\n| Environment | SDK usage |\n|---|---|\n| Client Components | Full SDK — auth, queries, storage, realtime |\n| Server Components | No direct SDK — call API routes, server actions, or middleware-derived data |\n| Middleware | `@volcano.dev/sdk/next/middleware` helpers |\n| API Routes / Route Handlers | Server client for token validation; full SDK with the bearer token for queries |\n\n## Shared Client Pattern (lib/volcano.ts)\n```ts\n// lib/volcano.ts\nimport { VolcanoAuth } from '@volcano.dev/sdk';\n\nlet volcano: VolcanoAuth | null = null;\n\nexport function getVolcano(): VolcanoAuth {\n  if (!volcano) {\n    volcano = new VolcanoAuth({\n      apiUrl: process.env.NEXT_PUBLIC_VOLCANO_API_URL!,\n      anonKey: process.env.NEXT_PUBLIC_VOLCANO_ANON_KEY!,\n    });\n    volcano.database(process.env.NEXT_PUBLIC_VOLCANO_DATABASE_NAME!);\n  }\n  return volcano;\n}\n```\n\n## AuthContext Provider (Client Component)\n```tsx\n// context/AuthContext.tsx\n'use client';\n\nimport { createContext, useContext, useEffect, useState, useCallback, ReactNode } from 'react';\nimport { getVolcano } from '@/lib/volcano';\nimport type { User } from '@volcano.dev/sdk';\n\ninterface AuthContextType {\n  user: User | null;\n  loading: boolean;\n  signIn: (email: string, password: string) => Promise<void>;\n  signUp: (email: string, password: string) => Promise<void>;\n  signOut: () => Promise<void>;\n}\n\nconst AuthContext = createContext<AuthContextType | null>(null);\n\nexport function AuthProvider({ children }: { children: ReactNode }) {\n  const [user, setUser] = useState<User | null>(null);\n  const [loading, setLoading] = useState(true);\n\n  useEffect(() => {\n    const volcano = getVolcano();\n\n    // Restore session on mount\n    volcano.initialize().then(({ user }) => {\n      setUser(user);\n      setLoading(false);\n    });\n\n    // React to subsequent changes\n    const unsubscribe = volcano.auth.onAuthStateChange(setUser);\n    return () => unsubscribe();\n  }, []);\n\n  const signIn = useCallback(async (email: string, password: string) => {\n    const { error } = await getVolcano().auth.signIn({ email, password });\n    if (error) throw error;\n  }, []);\n\n  const signUp = useCallback(async (email: string, password: string) => {\n    const { error } = await getVolcano().auth.signUp({ email, password });\n    if (error) throw error;\n  }, []);\n\n  const signOut = useCallback(async () => {\n    await getVolcano().auth.signOut();\n  }, []);\n\n  return (\n    <AuthContext.Provider value={{ user, loading, signIn, signUp, signOut }}>\n      {children}\n    </AuthContext.Provider>\n  );\n}\n\nexport function useAuth() {\n  const ctx = useContext(AuthContext);\n  if (!ctx) throw new Error('useAuth must be used within an AuthProvider');\n  return ctx;\n}\n```\n\nWire in `app/layout.tsx`:\n```tsx\nimport { AuthProvider } from '@/context/AuthContext';\n\nexport default function RootLayout({ children }: { children: React.ReactNode }) {\n  return (\n    <html lang=\"en\"><body><AuthProvider>{children}</AuthProvider></body></html>\n  );\n}\n```\n\n## Protected Page (Client Component)\n```tsx\n'use client';\nimport { useAuth } from '@/context/AuthContext';\nimport { useRouter } from 'next/navigation';\nimport { useEffect, useState } from 'react';\nimport { getVolcano } from '@/lib/volcano';\n\nexport default function DashboardPage() {\n  const { user, loading } = useAuth();\n  const router = useRouter();\n  const [posts, setPosts] = useState<any[]>([]);\n\n  useEffect(() => {\n    if (!loading && !user) router.push('/login');\n  }, [user, loading, router]);\n\n  useEffect(() => {\n    if (!user) return;\n    getVolcano()\n      .from('posts')\n      .select('*')\n      .order('created_at', { ascending: false })\n      .limit(10)\n      .then(({ data }) => setPosts(data ?? []));\n  }, [user]);\n\n  if (loading) return <div>Loading...</div>;\n  if (!user) return null;\n\n  return (\n    <div>\n      <h1>Welcome, {user.email}</h1>\n      <ul>{posts.map((p) => <li key={p.id}>{p.title}</li>)}</ul>\n    </div>\n  );\n}\n```\n\n## Middleware (Edge auth checks)\n```ts\n// middleware.ts\nimport { NextResponse } from 'next/server';\nimport type { NextRequest } from 'next/server';\nimport { createServerClient, withAuth } from '@volcano.dev/sdk/next/middleware';\n\nexport async function middleware(request: NextRequest) {\n  const client = createServerClient({\n    anonKey: process.env.NEXT_PUBLIC_VOLCANO_ANON_KEY!,\n    apiUrl: process.env.NEXT_PUBLIC_VOLCANO_API_URL,\n  });\n\n  const user = await withAuth(request, client);\n\n  if (request.nextUrl.pathname.startsWith('/dashboard') && !user) {\n    return NextResponse.redirect(new URL('/login', request.url));\n  }\n  if (request.nextUrl.pathname === '/login' && user) {\n    return NextResponse.redirect(new URL('/dashboard', request.url));\n  }\n  return NextResponse.next();\n}\n\nexport const config = {\n  matcher: ['/dashboard/:path*', '/login'],\n};\n```\n\n## Middleware Helpers API\n```ts\nimport {\n  createServerClient,\n  withAuth,\n  getTokenFromRequest,\n  isBrowser,\n  isServer,\n} from '@volcano.dev/sdk/next/middleware';\n\nconst client = createServerClient({ anonKey, apiUrl }); // apiUrl optional\n\nconst user = await withAuth(request, client);     // null when invalid/missing\nconst token = getTokenFromRequest(request);       // Authorization header or cookie\nconst { user, error } = await client.getUser(token);\nconst { accessToken, refreshToken, error } = await client.refreshToken(oldRefreshToken);\n\nif (isBrowser()) { /* ... */ }\nif (isServer())  { /* ... */ }\n```\n\n## API Routes — App Router (Route Handlers)\n```ts\n// app/api/posts/route.ts\nimport { NextRequest, NextResponse } from 'next/server';\nimport { createServerClient, getTokenFromRequest } from '@volcano.dev/sdk/next/middleware';\nimport { VolcanoAuth } from '@volcano.dev/sdk';\n\nexport async function GET(request: NextRequest) {\n  const client = createServerClient({\n    anonKey: process.env.NEXT_PUBLIC_VOLCANO_ANON_KEY!,\n    apiUrl: process.env.NEXT_PUBLIC_VOLCANO_API_URL,\n  });\n\n  const token = getTokenFromRequest(request);\n  if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });\n\n  const { user, error } = await client.getUser(token);\n  if (error || !user) return NextResponse.json({ error: 'Invalid token' }, { status: 401 });\n\n  // Use the SDK with the bearer token for queries / function calls\n  const volcano = new VolcanoAuth({\n    apiUrl: process.env.NEXT_PUBLIC_VOLCANO_API_URL!,\n    anonKey: process.env.NEXT_PUBLIC_VOLCANO_ANON_KEY!,\n    accessToken: token,\n  });\n\n  const { data, error: queryError } = await volcano.functions.invoke('get-user-posts');\n  if (queryError) return NextResponse.json({ error: queryError.message }, { status: 500 });\n\n  return NextResponse.json({ posts: data });\n}\n```\n\n## API Routes — Pages Router\n```ts\n// pages/api/posts.ts\nimport type { NextApiRequest, NextApiResponse } from 'next';\nimport { VolcanoAuth } from '@volcano.dev/sdk';\n\nexport default async function handler(req: NextApiRequest, res: NextApiResponse) {\n  const token = req.headers.authorization?.replace('Bearer ', '');\n  if (!token) return res.status(401).json({ error: 'Unauthorized' });\n\n  const volcano = new VolcanoAuth({\n    apiUrl: process.env.NEXT_PUBLIC_VOLCANO_API_URL!,\n    anonKey: process.env.NEXT_PUBLIC_VOLCANO_ANON_KEY!,\n    accessToken: token,\n  });\n  volcano.database(process.env.NEXT_PUBLIC_VOLCANO_DATABASE_NAME!);\n\n  const { data, error } = await volcano\n    .from('posts')\n    .select('*')\n    .order('created_at', { ascending: false });\n  if (error) return res.status(500).json({ error: error.message });\n\n  return res.status(200).json({ posts: data });\n}\n```\n\n## Cookie Sync — required for Server Actions and middleware token reads\n\nThe SDK stores tokens in localStorage by default. Server Actions, route handlers, and middleware run on the server and cannot read localStorage — they read cookies. Add a small client component that mirrors the access token to a cookie whenever auth state changes:\n\n```tsx\n// components/CookieSync.tsx\n'use client';\nimport { useEffect } from 'react';\nimport { getVolcano } from '@/lib/volcano';\n\nexport function CookieSync() {\n  useEffect(() => {\n    const volcano = getVolcano();\n    const sync = (token: string | null) => {\n      const base = 'Path=/; SameSite=Lax; Secure';\n      document.cookie = token\n        ? `volcano_access_token=${token}; ${base}; Max-Age=3600`\n        : `volcano_access_token=; ${base}; Max-Age=0`;\n    };\n    sync(volcano.accessToken ?? null);\n    const unsub = volcano.auth.onAuthStateChange(() => {\n      sync(volcano.accessToken ?? null);\n    });\n    return () => unsub();\n  }, []);\n  return null;\n}\n```\n\nMount it once, alongside the `AuthProvider`:\n\n```tsx\n// app/layout.tsx\nimport { AuthProvider } from '@/context/AuthContext';\nimport { CookieSync } from '@/components/CookieSync';\n\nexport default function RootLayout({ children }: { children: React.ReactNode }) {\n  return (\n    <html lang=\"en\">\n      <body>\n        <AuthProvider>\n          <CookieSync />\n          {children}\n        </AuthProvider>\n      </body>\n    </html>\n  );\n}\n```\n\n**Security note:** This cookie is JS-readable, which matches the SDK's localStorage exposure — the XSS surface area is identical. For higher-security postures, set an `httpOnly` cookie from an API route after sign-in and read it server-side via `getTokenFromRequest`. The client-mirrored pattern shown above is the simplest route to a working Server Action.\n\n## Server Components & Server Actions\nServer Components don't have localStorage. Three options:\n1. Fetch from API routes (which validate the token).\n2. Use Server Actions with the SDK and the cookie token.\n3. Pass middleware-derived data via headers.\n\n### Server Action template\n```ts\n// app/actions.ts\n'use server';\n\nimport { cookies } from 'next/headers';\nimport { createServerClient } from '@volcano.dev/sdk/next/middleware';\nimport { VolcanoAuth } from '@volcano.dev/sdk';\n\nexport async function getPosts() {\n  const cookieStore = cookies();\n  const token = cookieStore.get('volcano_access_token')?.value;\n  if (!token) throw new Error('Not authenticated');\n\n  const client = createServerClient({\n    anonKey: process.env.NEXT_PUBLIC_VOLCANO_ANON_KEY!,\n    apiUrl: process.env.NEXT_PUBLIC_VOLCANO_API_URL,\n  });\n  const { user, error } = await client.getUser(token);\n  if (error || !user) throw new Error('Invalid session');\n\n  const volcano = new VolcanoAuth({\n    apiUrl: process.env.NEXT_PUBLIC_VOLCANO_API_URL!,\n    anonKey: process.env.NEXT_PUBLIC_VOLCANO_ANON_KEY!,\n    accessToken: token,\n  });\n  volcano.database(process.env.NEXT_PUBLIC_VOLCANO_DATABASE_NAME!);\n\n  const { data } = await volcano\n    .from('posts')\n    .select('id, title, created_at')\n    .order('created_at', { ascending: false })\n    .limit(10);\n  return data;\n}\n```\n\n```tsx\n// app/posts/page.tsx\nimport { getPosts } from '@/app/actions';\n\nexport default async function PostsPage() {\n  const posts = await getPosts();\n  return <ul>{posts?.map((p) => <li key={p.id}>{p.title}</li>)}</ul>;\n}\n```\n\n## Realtime in Next.js (Client Component required)\n```tsx\n'use client';\nimport { useEffect, useState } from 'react';\nimport { VolcanoRealtime } from '@volcano.dev/sdk/realtime';\nimport { getVolcano } from '@/lib/volcano';\nimport { useAuth } from '@/context/AuthContext';\n\nexport function LivePosts() {\n  const { user } = useAuth();\n  const [posts, setPosts] = useState<any[]>([]);\n\n  useEffect(() => {\n    if (!user) return;\n    const volcano = getVolcano();\n\n    volcano\n      .from('posts')\n      .select('*')\n      .order('created_at', { ascending: false })\n      .then(({ data }) => setPosts(data ?? []));\n\n    const realtime = new VolcanoRealtime({\n      apiUrl: process.env.NEXT_PUBLIC_VOLCANO_API_URL!,\n      anonKey: process.env.NEXT_PUBLIC_VOLCANO_ANON_KEY!,\n      accessToken: volcano.accessToken!,\n    });\n\n    realtime.connect().then(() => {\n      const channel = realtime.channel('posts', { type: 'postgres' });\n      channel.onPostgresChanges('INSERT', 'public', 'posts', (c) =>\n        setPosts((cur) => [c.record, ...cur]));\n      channel.onPostgresChanges('UPDATE', 'public', 'posts', (c) =>\n        setPosts((cur) => cur.map((p) => (p.id === c.record.id ? c.record : p))));\n      channel.onPostgresChanges('DELETE', 'public', 'posts', (c) =>\n        setPosts((cur) => cur.filter((p) => p.id !== c.old_record.id)));\n      channel.subscribe();\n    });\n\n    return () => { realtime.disconnect(); };\n  }, [user]);\n\n  return <ul>{posts.map((p) => <li key={p.id}>{p.title}</li>)}</ul>;\n}\n```\n\n## Default Signup & Login Pages\nWhen the prompt doesn't specify signup/login page design, apply the `volcano-auth` \"Default Signup & Login Page UX\" and the shared `volcano-uiux` rules — including the default signup-success alert — on top of the `AuthContext` from this skill. The examples below show Next.js auth wiring; add the labels, progress feedback, responsive layout, and complete states required by `volcano-uiux`.\n\n```tsx\n// app/signup/page.tsx — default signup page with a success alert\n'use client';\nimport { useState } from 'react';\nimport { useRouter } from 'next/navigation';\nimport Link from 'next/link';\nimport { useAuth } from '@/context/AuthContext';\n\nexport default function SignupPage() {\n  const { signUp } = useAuth();\n  const router = useRouter();\n  const [email, setEmail] = useState('');\n  const [password, setPassword] = useState('');\n  const [error, setError] = useState<string | null>(null);\n  const [success, setSuccess] = useState(false);\n  const [submitting, setSubmitting] = useState(false);\n\n  const handleSubmit = async (e: React.FormEvent) => {\n    e.preventDefault();\n    if (submitting || success) return;\n    setError(null);\n    setSubmitting(true);\n    try {\n      await signUp(email, password);\n      setSuccess(true); // default \"Signup success\" alert — shown even if not requested\n      setSubmitting(false);\n      setTimeout(() => router.push('/login'), 2000);\n    } catch (err: any) {\n      setError(err.message);\n      setSubmitting(false);\n    }\n  };\n\n  return (\n    <div>\n      {success && (\n        <div role=\"alert\">Signup successful! Check your email to verify your account.</div>\n      )}\n      {error && <div role=\"alert\">{error}</div>}\n      <form onSubmit={handleSubmit}>\n        <label htmlFor=\"signup-email\">Email</label>\n        <input id=\"signup-email\" type=\"email\" autoComplete=\"email\" value={email} onChange={(e) => setEmail(e.target.value)} required />\n        <label htmlFor=\"signup-password\">Password</label>\n        <input id=\"signup-password\" type=\"password\" autoComplete=\"new-password\" value={password} onChange={(e) => setPassword(e.target.value)} required />\n        <button type=\"submit\" disabled={submitting || success} aria-busy={submitting}>\n          {success ? 'Account created' : submitting ? 'Signing up…' : 'Sign Up'}\n        </button>\n        <span role=\"status\">{submitting ? 'Creating account…' : ''}</span>\n      </form>\n      <Link href=\"/login\">Already have an account? Log in</Link>\n    </div>\n  );\n}\n```\n\n```tsx\n// app/login/page.tsx — default login page (email/password)\n'use client';\nimport { useState } from 'react';\nimport { useRouter } from 'next/navigation';\nimport Link from 'next/link';\nimport { useAuth } from '@/context/AuthContext';\n\nexport default function LoginPage() {\n  const { signIn } = useAuth();\n  const router = useRouter();\n  const [email, setEmail] = useState('');\n  const [password, setPassword] = useState('');\n  const [error, setError] = useState<string | null>(null);\n  const [submitting, setSubmitting] = useState(false);\n\n  const handleSubmit = async (e: React.FormEvent) => {\n    e.preventDefault();\n    if (submitting) return;\n    setError(null);\n    setSubmitting(true);\n    try {\n      await signIn(email, password);\n      router.push('/dashboard');\n    } catch (err: any) {\n      setError(err.message);\n      setSubmitting(false);\n    }\n  };\n\n  return (\n    <div>\n      {error && <div role=\"alert\">{error}</div>}\n      <form onSubmit={handleSubmit}>\n        <label htmlFor=\"login-email\">Email</label>\n        <input id=\"login-email\" type=\"email\" autoComplete=\"email\" value={email} onChange={(e) => setEmail(e.target.value)} required />\n        <label htmlFor=\"login-password\">Password</label>\n        <input id=\"login-password\" type=\"password\" autoComplete=\"current-password\" value={password} onChange={(e) => setPassword(e.target.value)} required />\n        <button type=\"submit\" disabled={submitting} aria-busy={submitting}>\n          {submitting ? 'Signing in…' : 'Sign In'}\n        </button>\n        <span role=\"status\">{submitting ? 'Signing in…' : ''}</span>\n      </form>\n      <Link href=\"/signup\">Need an account? Sign up</Link>\n      <Link href=\"/forgot-password\">Forgot password?</Link>\n    </div>\n  );\n}\n```\n\n## OAuth in Next.js\nOAuth initiation must be in a Client Component; the callback page reconciles the tokens. Add the OAuth button(s) to the default login page above (or use standalone, as shown) — don't replace the email/password form unless the user asks for OAuth-only login.\n\n```tsx\n// Add to app/login/page.tsx (or standalone) — OAuth button\n'use client';\nimport { getVolcano } from '@/lib/volcano';\n\nexport function GoogleSignInButton() {\n  return (\n    <button onClick={() => getVolcano().auth.signInWithGoogle()}>\n      Sign in with Google\n    </button>\n  );\n}\n```\n\n```tsx\n// app/auth/callback/page.tsx\n'use client';\nimport { useEffect } from 'react';\nimport { useRouter } from 'next/navigation';\nimport { getVolcano } from '@/lib/volcano';\n\nexport default function AuthCallbackPage() {\n  const router = useRouter();\n  useEffect(() => {\n    getVolcano().initialize().then(({ user }) => {\n      router.push(user ? '/dashboard' : '/login?error=auth_failed');\n    });\n  }, [router]);\n  return <div>Completing sign in...</div>;\n}\n```\n\n## SSR Safety\n- `volcano.auth.signInWith*` (OAuth) only works in browser; calling it server-side throws.\n- The SDK rejects service keys (`sk-...`) in browser environments.\n- Use `isBrowser()` / `isServer()` from middleware helpers when branching.\n\n## Best Practices\n- **Single client instance** via `getVolcano()`. Don't `new VolcanoAuth(...)` in components.\n- **Loading state** is a first-class case — don't render protected UI before `useAuth().loading === false`.\n- **Pair `connect()` with `disconnect()`** for realtime in `useEffect` cleanup.\n- **Validate tokens server-side** — do not trust client-supplied user state in API routes / actions.\n- **Use `NEXT_PUBLIC_` only for non-secrets**; service keys never get this prefix.\n\n## Verification Checklist\n- User-facing interfaces pass the `volcano-uiux` verification checklist.\n- Client/server responsibilities are clear and correct.\n- Middleware uses `withAuth` (or equivalent) before allowing protected routes.\n- Server components fetch via API routes / server actions / middleware data — never call browser SDK methods directly.\n- Imports, env vars, dependencies, and init order are consistent.\n- Realtime is wrapped in a Client Component with proper cleanup.\n\n## Optional Fallback References\n"
}

SHA-256 of public snapshot: ac58dc74322c1d260540c4c66af6638995a9026c9693d10400ec6aea57800e4b