Handle every failure mode from the Lynkow SDK gracefully -- show the right UI, retry when appropriate, and log what matters.


Prerequisites

  • lynkow SDK installed and configured (Getting Started guide)

  • Next.js 15 App Router project with TypeScript

  • Basic familiarity with React Error Boundaries


1. The LynkowError Class

Every error thrown by the SDK is a LynkowError instance. It extends the native Error with structured fields:

TypeScript
import { LynkowError, isLynkowError } from 'lynkow'

// LynkowError extends Error
interface LynkowError extends Error {
  /** Machine-readable error classification */
  code: ErrorCode

  /** HTTP status code (undefined for TIMEOUT and NETWORK_ERROR) */
  status?: number

  /** Field-level validation errors (only present for VALIDATION_ERROR) */
  details?: ApiErrorDetail[]

  /** The original underlying error (AbortError for TIMEOUT, TypeError for NETWORK_ERROR) */
  cause?: Error

  /** Serialize for logging / external services */
  toJSON(): Record<string, unknown>
}

interface ApiErrorDetail {
  message: string
  field?: string
  code?: string
}

type ErrorCode =
  | 'BAD_REQUEST'
  | 'VALIDATION_ERROR'
  | 'UNAUTHORIZED'
  | 'FORBIDDEN'
  | 'NOT_FOUND'
  | 'RATE_LIMITED'
  | 'TOO_MANY_REQUESTS'
  | 'TIMEOUT'
  | 'NETWORK_ERROR'
  | 'INTERNAL_ERROR'
  | 'SERVICE_UNAVAILABLE'
  | 'UNKNOWN'

Key points:

  • code is always present and is a finite set of strings -- use it for branching logic instead of checking status.

  • status is the HTTP status code from the API. It is undefined for client-side failures (TIMEOUT, NETWORK_ERROR) where no HTTP response was received.

  • details is only populated for VALIDATION_ERROR. Each entry has a message and optionally a field name that maps to your form input.

  • cause preserves the original error: an AbortError for timeouts, a TypeError for network failures. Useful for debugging.

  • toJSON() produces a plain object suitable for structured logging.


2. Using the isLynkowError Type Guard

The isLynkowError function narrows unknown to LynkowError. Always use it in catch blocks since you cannot know what was thrown:

TypeScript
import { isLynkowError } from 'lynkow'

try {
  const article = await lynkow.contents.getBySlug('my-article')
} catch (error: unknown) {
  if (isLynkowError(error)) {
    // TypeScript knows error is LynkowError here
    console.log(error.code)    // 'NOT_FOUND'
    console.log(error.status)  // 404
    console.log(error.details) // undefined (not a validation error)
  } else {
    // Not from the SDK -- a programming error, JSON parse failure, etc.
    console.error('Unexpected error:', error)
  }
}

3. Error Code Reference

Code

HTTP Status

When It Occurs

Recommended Action

BAD_REQUEST

400

Malformed request parameters

Fix the request -- check parameter types and formats

VALIDATION_ERROR

422

Invalid field values (e.g., form submission)

Display details[].message next to corresponding fields

UNAUTHORIZED

401

Invalid or missing siteId / API key

Check SDK configuration -- likely a wrong siteId

FORBIDDEN

403

Valid auth but insufficient permissions

The resource exists but is not accessible publicly

NOT_FOUND

404

Resource does not exist or is not published

Show 404 page or fallback content

RATE_LIMITED

429

Too many requests in a short period

Retry with exponential backoff

TOO_MANY_REQUESTS

429

Sustained rate limit violation

Back off longer, consider caching

TIMEOUT

undefined

Request exceeded time limit (no response received)

Retry or show fallback UI with retry button

NETWORK_ERROR

undefined

DNS failure, offline, connection refused

Show offline indicator, retry when connectivity returns

INTERNAL_ERROR

500

Server-side bug or crash

Log for monitoring, show generic error page

SERVICE_UNAVAILABLE

503

API is down for maintenance or overloaded

Show maintenance page, retry after delay

UNKNOWN

varies

Unclassified error

Log the full error for investigation


4. Pattern: NOT_FOUND -- Trigger Next.js 404

When a content page receives NOT_FOUND, call notFound() from next/navigation to render the closest not-found.tsx:

TypeScript
// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation'
import { lynkow, isLynkowError } from '@/lib/lynkow'

interface Props {
  params: Promise<{ slug: string }>
}

export default async function ArticlePage({ params }: Props) {
  const { slug } = await params

  try {
    const article = await lynkow.contents.getBySlug(slug)

    return (
      <article>
        <h1>{article.title}</h1>
        <div dangerouslySetInnerHTML={{ __html: article.html }} />
      </article>
    )
  } catch (error: unknown) {
    if (isLynkowError(error) && error.code === 'NOT_FOUND') {
      notFound()
    }
    throw error // re-throw everything else to the error boundary
  }
}

Create the matching not-found.tsx (see section 13 for the full implementation).


5. Pattern: VALIDATION_ERROR -- Display Field Errors Inline

When submitting a form (e.g., contact form, review), map details back to form fields:

TypeScript
// app/contact/contact-form.tsx
'use client'

import { useState } from 'react'
import { lynkow, isLynkowError, type LynkowError } from 'lynkow'

interface FieldErrors {
  [field: string]: string
}

export function ContactForm() {
  const [fieldErrors, setFieldErrors] = useState<FieldErrors>({})
  const [globalError, setGlobalError] = useState<string | null>(null)

  async function handleSubmit(formData: FormData) {
    setFieldErrors({})
    setGlobalError(null)

    try {
      await lynkow.forms.submit('contact', {
        name: formData.get('name') as string,
        email: formData.get('email') as string,
        message: formData.get('message') as string,
      })
    } catch (error: unknown) {
      if (isLynkowError(error) && error.code === 'VALIDATION_ERROR' && error.details) {
        // Map each detail to its field
        const errors: FieldErrors = {}
        for (const detail of error.details) {
          if (detail.field) {
            errors[detail.field] = detail.message
          }
        }
        setFieldErrors(errors)

        // Collect any errors without a field for a global message
        const globalErrors = error.details
          .filter((d) => !d.field)
          .map((d) => d.message)
        if (globalErrors.length > 0) {
          setGlobalError(globalErrors.join('. '))
        }
        return
      }

      setGlobalError('Something went wrong. Please try again.')
    }
  }

  return (
    <form action={handleSubmit}>
      {globalError && (
        <div role="alert" className="mb-4 rounded bg-red-50 p-3 text-sm text-red-700">
          {globalError}
        </div>
      )}

      <div className="mb-4">
        <label htmlFor="name" className="block text-sm font-medium">
          Name
        </label>
        <input
          id="name"
          name="name"
          className={`mt-1 block w-full rounded border px-3 py-2 ${
            fieldErrors.name ? 'border-red-500' : 'border-gray-300'
          }`}
        />
        {fieldErrors.name && (
          <p className="mt-1 text-sm text-red-600">{fieldErrors.name}</p>
        )}
      </div>

      <div className="mb-4">
        <label htmlFor="email" className="block text-sm font-medium">
          Email
        </label>
        <input
          id="email"
          name="email"
          type="email"
          className={`mt-1 block w-full rounded border px-3 py-2 ${
            fieldErrors.email ? 'border-red-500' : 'border-gray-300'
          }`}
        />
        {fieldErrors.email && (
          <p className="mt-1 text-sm text-red-600">{fieldErrors.email}</p>
        )}
      </div>

      <div className="mb-4">
        <label htmlFor="message" className="block text-sm font-medium">
          Message
        </label>
        <textarea
          id="message"
          name="message"
          rows={4}
          className={`mt-1 block w-full rounded border px-3 py-2 ${
            fieldErrors.message ? 'border-red-500' : 'border-gray-300'
          }`}
        />
        {fieldErrors.message && (
          <p className="mt-1 text-sm text-red-600">{fieldErrors.message}</p>
        )}
      </div>

      <button
        type="submit"
        className="rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
      >
        Send
      </button>
    </form>
  )
}

6. Pattern: RATE_LIMITED -- Exponential Backoff Retry

When the API returns 429, the SDK throws with code: 'RATE_LIMITED'. Don't retry immediately -- wait with exponential backoff:

TypeScript
import { isLynkowError } from 'lynkow'

async function fetchWithBackoff<T>(
  fn: () => Promise<T>,
  maxRetries = 3,
  baseDelayMs = 1000
): Promise<T> {
  let attempt = 0

  while (true) {
    try {
      return await fn()
    } catch (error: unknown) {
      attempt++
      if (
        isLynkowError(error) &&
        (error.code === 'RATE_LIMITED' || error.code === 'TOO_MANY_REQUESTS') &&
        attempt <= maxRetries
      ) {
        const delay = baseDelayMs * Math.pow(2, attempt - 1)
        const jitter = Math.random() * baseDelayMs * 0.5
        await new Promise((resolve) => setTimeout(resolve, delay + jitter))
        continue
      }
      throw error
    }
  }
}

// Usage
const articles = await fetchWithBackoff(() => lynkow.contents.list({ limit: 20 }))

7. Pattern: NETWORK_ERROR / TIMEOUT -- Fallback UI with Retry

On the client side, network errors mean the user is offline or the API is unreachable. Show a fallback with a retry button:

TypeScript
// app/blog/article-loader.tsx
'use client'

import { useState, useEffect, useCallback } from 'react'
import { lynkow, isLynkowError, type Content } from 'lynkow'

interface Props {
  slug: string
}

export function ArticleLoader({ slug }: Props) {
  const [article, setArticle] = useState<Content | null>(null)
  const [error, setError] = useState<string | null>(null)
  const [isRetryable, setIsRetryable] = useState(false)
  const [loading, setLoading] = useState(true)

  const fetchArticle = useCallback(async () => {
    setLoading(true)
    setError(null)
    setIsRetryable(false)

    try {
      const data = await lynkow.contents.getBySlug(slug)
      setArticle(data)
    } catch (err: unknown) {
      if (isLynkowError(err)) {
        switch (err.code) {
          case 'NETWORK_ERROR':
            setError('Unable to connect. Check your internet connection and try again.')
            setIsRetryable(true)
            break
          case 'TIMEOUT':
            setError('The request timed out. Please try again.')
            setIsRetryable(true)
            break
          case 'SERVICE_UNAVAILABLE':
            setError('The service is temporarily unavailable. Please try again in a moment.')
            setIsRetryable(true)
            break
          case 'NOT_FOUND':
            setError('This article could not be found.')
            setIsRetryable(false)
            break
          default:
            setError('Something went wrong. Please try again later.')
            setIsRetryable(true)
        }
      } else {
        setError('An unexpected error occurred.')
        setIsRetryable(false)
      }
    } finally {
      setLoading(false)
    }
  }, [slug])

  useEffect(() => {
    fetchArticle()
  }, [fetchArticle])

  if (loading) {
    return <div className="animate-pulse h-64 rounded bg-gray-100" />
  }

  if (error) {
    return (
      <div className="rounded border border-red-200 bg-red-50 p-6 text-center">
        <p className="mb-4 text-red-700">{error}</p>
        {isRetryable && (
          <button
            onClick={fetchArticle}
            className="rounded bg-red-600 px-4 py-2 text-white hover:bg-red-700"
          >
            Try Again
          </button>
        )}
      </div>
    )
  }

  if (!article) return null

  return (
    <article>
      <h1 className="text-3xl font-bold">{article.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: article.html }} />
    </article>
  )
}

8. Pattern: UNAUTHORIZED -- Configuration Error

A 401 Unauthorized from the public SDK almost always means the siteId is wrong or missing. The public SDK does not use user credentials -- only a site identifier:

TypeScript
import { isLynkowError } from 'lynkow'

try {
  const articles = await lynkow.contents.list()
} catch (error: unknown) {
  if (isLynkowError(error) && error.code === 'UNAUTHORIZED') {
    // This is a configuration problem, not a user problem.
    // In development, throw a clear message:
    if (process.env.NODE_ENV === 'development') {
      throw new Error(
        `Lynkow UNAUTHORIZED: Check that NEXT_PUBLIC_LYNKOW_SITE_ID is set correctly. ` +
        `Current value: "${process.env.NEXT_PUBLIC_LYNKOW_SITE_ID}". ` +
        `Original error: ${error.message}`
      )
    }
    // In production, log and show a generic error
    console.error('[Lynkow] Configuration error: invalid siteId', error.toJSON())
    throw error
  }
  throw error
}

Tip: If you receive FORBIDDEN (403) instead, the site exists but the requested resource may require authentication via the V1 admin API (e.g., draft content). See the Draft Preview guide.


9. Building a withRetry Utility

A generic retry wrapper that handles both rate limiting and transient network errors:

TypeScript
// lib/with-retry.ts
import { isLynkowError } from 'lynkow'

interface RetryOptions {
  /** Maximum number of retry attempts (default: 3) */
  maxRetries?: number
  /** Base delay in milliseconds before first retry (default: 1000) */
  baseDelayMs?: number
  /** Maximum delay cap in milliseconds (default: 30000) */
  maxDelayMs?: number
  /** Error codes that should trigger a retry */
  retryOn?: string[]
}

const DEFAULT_RETRYABLE_CODES = [
  'RATE_LIMITED',
  'TOO_MANY_REQUESTS',
  'TIMEOUT',
  'NETWORK_ERROR',
  'SERVICE_UNAVAILABLE',
]

export async function withRetry<T>(
  fn: () => Promise<T>,
  options: RetryOptions = {}
): Promise<T> {
  const {
    maxRetries = 3,
    baseDelayMs = 1000,
    maxDelayMs = 30_000,
    retryOn = DEFAULT_RETRYABLE_CODES,
  } = options

  let lastError: unknown

  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      return await fn()
    } catch (error: unknown) {
      lastError = error

      // Only retry LynkowErrors with retryable codes
      if (!isLynkowError(error) || !retryOn.includes(error.code)) {
        throw error
      }

      // Don't wait after the last attempt
      if (attempt === maxRetries) {
        break
      }

      // Exponential backoff with jitter
      const exponentialDelay = baseDelayMs * Math.pow(2, attempt)
      const jitter = Math.random() * baseDelayMs
      const delay = Math.min(exponentialDelay + jitter, maxDelayMs)

      await new Promise((resolve) => setTimeout(resolve, delay))
    }
  }

  throw lastError
}

Usage in server components and data fetching:

TypeScript
// app/blog/page.tsx
import { lynkow } from '@/lib/lynkow'
import { withRetry } from '@/lib/with-retry'

export default async function BlogPage() {
  const articles = await withRetry(
    () => lynkow.contents.list({ limit: 20 }),
    { maxRetries: 2, baseDelayMs: 500 }
  )

  return (
    <ul>
      {articles.data.map((article) => (
        <li key={article.id}>{article.title}</li>
      ))}
    </ul>
  )
}

10. React Error Boundary for Graceful Degradation

Error boundaries catch rendering errors in their subtree. Combine them with isLynkowError to show context-appropriate fallbacks:

TypeScript
// components/error-boundary.tsx
'use client'

import { Component, type ReactNode } from 'react'
import { isLynkowError } from 'lynkow'

interface Props {
  children: ReactNode
  /** Rendered when a non-retryable error occurs */
  fallback?: ReactNode
}

interface State {
  error: unknown
  hasError: boolean
}

export class LynkowErrorBoundary extends Component<Props, State> {
  constructor(props: Props) {
    super(props)
    this.state = { error: null, hasError: false }
  }

  static getDerivedStateFromError(error: unknown): State {
    return { error, hasError: true }
  }

  componentDidCatch(error: unknown) {
    if (isLynkowError(error)) {
      console.error(
        `[LynkowError] code=${error.code} status=${error.status}`,
        error.toJSON()
      )
    }
  }

  private reset = () => {
    this.setState({ error: null, hasError: false })
  }

  render() {
    if (!this.state.hasError) {
      return this.props.children
    }

    const { error } = this.state

    if (isLynkowError(error)) {
      switch (error.code) {
        case 'NETWORK_ERROR':
        case 'TIMEOUT':
        case 'SERVICE_UNAVAILABLE':
          return (
            <div className="rounded border border-amber-200 bg-amber-50 p-6 text-center">
              <h2 className="mb-2 text-lg font-semibold text-amber-800">
                Connection Issue
              </h2>
              <p className="mb-4 text-amber-700">
                We couldn&apos;t load this content. Please check your connection.
              </p>
              <button
                onClick={this.reset}
                className="rounded bg-amber-600 px-4 py-2 text-white hover:bg-amber-700"
              >
                Retry
              </button>
            </div>
          )

        case 'NOT_FOUND':
          return (
            <div className="rounded border border-gray-200 bg-gray-50 p-6 text-center">
              <h2 className="text-lg font-semibold text-gray-600">Not Found</h2>
              <p className="text-gray-500">This content is no longer available.</p>
            </div>
          )

        default:
          // INTERNAL_ERROR, UNKNOWN, etc.
          return (
            <div className="rounded border border-red-200 bg-red-50 p-6 text-center">
              <h2 className="mb-2 text-lg font-semibold text-red-800">
                Something went wrong
              </h2>
              <p className="mb-4 text-sm text-red-600">
                Error code: {error.code}
              </p>
              <button
                onClick={this.reset}
                className="rounded bg-red-600 px-4 py-2 text-white hover:bg-red-700"
              >
                Try Again
              </button>
            </div>
          )
      }
    }

    // Non-Lynkow error -- use the provided fallback or a generic message
    return (
      this.props.fallback ?? (
        <div className="rounded border border-red-200 bg-red-50 p-6 text-center">
          <p className="text-red-700">An unexpected error occurred.</p>
        </div>
      )
    )
  }
}

Usage:

tsx
// app/blog/[slug]/page.tsx (layout wrapping)
import { LynkowErrorBoundary } from '@/components/error-boundary'
import { ArticleLoader } from './article-loader'

export default async function ArticlePage({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params

  return (
    <LynkowErrorBoundary>
      <ArticleLoader slug={slug} />
    </LynkowErrorBoundary>
  )
}

11. Logging to External Services

Use error.toJSON() to send structured error data to monitoring services like Sentry:

TypeScript
// lib/error-logging.ts
import * as Sentry from '@sentry/nextjs'
import { isLynkowError } from 'lynkow'

export function captureError(error: unknown, context?: Record<string, unknown>) {
  if (isLynkowError(error)) {
    Sentry.captureException(error, {
      tags: {
        'lynkow.code': error.code,
        'lynkow.status': error.status?.toString() ?? 'none',
      },
      extra: {
        ...error.toJSON(),
        ...context,
      },
      // Don't flood Sentry with rate limit errors
      level: error.code === 'RATE_LIMITED' ? 'warning' : 'error',
    })
  } else {
    Sentry.captureException(error, { extra: context })
  }
}

Integrate it into your data fetching:

TypeScript
// lib/lynkow-fetch.ts
import { lynkow, isLynkowError } from '@/lib/lynkow'
import { withRetry } from '@/lib/with-retry'
import { captureError } from '@/lib/error-logging'

export async function fetchContent(slug: string) {
  try {
    return await withRetry(() => lynkow.contents.getBySlug(slug))
  } catch (error: unknown) {
    captureError(error, { slug })
    throw error
  }
}

12. Server Component vs Client Component Error Handling

Error handling differs significantly between server and client components in Next.js.

Server Components

Server components run on the server during rendering. Errors propagate to the nearest error.tsx boundary:

TypeScript
// app/blog/[slug]/page.tsx (server component)
import { notFound } from 'next/navigation'
import { lynkow, isLynkowError } from '@/lib/lynkow'
import { captureError } from '@/lib/error-logging'

interface Props {
  params: Promise<{ slug: string }>
}

export default async function ArticlePage({ params }: Props) {
  const { slug } = await params

  try {
    const article = await lynkow.contents.getBySlug(slug)

    return (
      <article className="prose mx-auto max-w-3xl py-12">
        <h1>{article.title}</h1>
        <div dangerouslySetInnerHTML={{ __html: article.html }} />
      </article>
    )
  } catch (error: unknown) {
    if (isLynkowError(error)) {
      if (error.code === 'NOT_FOUND') {
        notFound() // renders not-found.tsx
      }
      // Log non-404 errors
      captureError(error, { slug })
    }
    throw error // propagates to error.tsx
  }
}

Key behaviors:

  • notFound() renders the nearest not-found.tsx -- it does not throw.

  • Thrown errors are caught by the nearest error.tsx in the route hierarchy.

  • Server components cannot use useState or retry buttons -- delegate interactive error recovery to a client component via error.tsx.

Client Components

Client components handle errors locally with try/catch and state:

TypeScript
// components/latest-articles.tsx
'use client'

import { useState, useEffect } from 'react'
import { lynkow, isLynkowError, type Content } from 'lynkow'

export function LatestArticles() {
  const [articles, setArticles] = useState<Content[]>([])
  const [error, setError] = useState<string | null>(null)
  const [loading, setLoading] = useState(true)

  async function load() {
    setLoading(true)
    setError(null)
    try {
      const response = await lynkow.contents.list({ limit: 5 })
      setArticles(response.data)
    } catch (err: unknown) {
      if (isLynkowError(err) && err.code === 'NETWORK_ERROR') {
        setError('You appear to be offline.')
      } else {
        setError('Failed to load articles.')
      }
    } finally {
      setLoading(false)
    }
  }

  useEffect(() => { load() }, [])

  if (loading) return <p>Loading...</p>
  if (error) {
    return (
      <div>
        <p>{error}</p>
        <button onClick={load}>Retry</button>
      </div>
    )
  }

  return (
    <ul>
      {articles.map((a) => (
        <li key={a.id}>{a.title}</li>
      ))}
    </ul>
  )
}

Key behaviors:

  • Errors are caught locally -- they do not bubble to error.tsx.

  • You can offer retry buttons immediately.

  • Use isLynkowError to give specific messages for different failure modes.


13. Complete error.tsx and not-found.tsx

error.tsx

This file catches all unhandled errors in its route segment. It must be a client component:

TypeScript
// app/error.tsx
'use client'

import { useEffect } from 'react'
import { isLynkowError } from 'lynkow'
import { captureError } from '@/lib/error-logging'

interface Props {
  error: Error & { digest?: string }
  reset: () => void
}

export default function GlobalError({ error, reset }: Props) {
  useEffect(() => {
    captureError(error)
  }, [error])

  // Determine the user-facing message based on the error type
  let title = 'Something went wrong'
  let description = 'An unexpected error occurred. Please try again.'
  let showRetry = true

  if (isLynkowError(error)) {
    switch (error.code) {
      case 'NETWORK_ERROR':
        title = 'Connection lost'
        description =
          'We could not reach the server. Please check your internet connection and try again.'
        break

      case 'TIMEOUT':
        title = 'Request timed out'
        description = 'The server took too long to respond. Please try again.'
        break

      case 'SERVICE_UNAVAILABLE':
        title = 'Service unavailable'
        description =
          'Our servers are temporarily down for maintenance. Please try again in a few minutes.'
        break

      case 'RATE_LIMITED':
      case 'TOO_MANY_REQUESTS':
        title = 'Too many requests'
        description = 'You are sending requests too quickly. Please wait a moment and try again.'
        break

      case 'UNAUTHORIZED':
        title = 'Configuration error'
        description =
          'The site could not be authenticated. If you are the site owner, check your Lynkow configuration.'
        showRetry = false
        break

      case 'INTERNAL_ERROR':
        title = 'Server error'
        description = 'Something went wrong on our end. Our team has been notified.'
        break
    }
  }

  return (
    <div className="flex min-h-[50vh] flex-col items-center justify-center px-4">
      <div className="max-w-md text-center">
        <h1 className="mb-4 text-2xl font-bold text-gray-900">{title}</h1>
        <p className="mb-8 text-gray-600">{description}</p>

        {error.digest && (
          <p className="mb-4 text-xs text-gray-400">Error ID: {error.digest}</p>
        )}

        <div className="flex justify-center gap-4">
          {showRetry && (
            <button
              onClick={reset}
              className="rounded bg-blue-600 px-6 py-2.5 text-sm font-medium text-white hover:bg-blue-700"
            >
              Try again
            </button>
          )}
          <a
            href="/"
            className="rounded border border-gray-300 px-6 py-2.5 text-sm font-medium text-gray-700 hover:bg-gray-50"
          >
            Go home
          </a>
        </div>
      </div>
    </div>
  )
}

not-found.tsx

This renders when notFound() is called or when a route does not match:

TypeScript
// app/not-found.tsx
import Link from 'next/link'

export default function NotFound() {
  return (
    <div className="flex min-h-[50vh] flex-col items-center justify-center px-4">
      <div className="max-w-md text-center">
        <p className="mb-2 text-6xl font-bold text-gray-300">404</p>
        <h1 className="mb-4 text-2xl font-bold text-gray-900">Page not found</h1>
        <p className="mb-8 text-gray-600">
          The page you are looking for does not exist or has been moved.
        </p>
        <div className="flex justify-center gap-4">
          <Link
            href="/"
            className="rounded bg-blue-600 px-6 py-2.5 text-sm font-medium text-white hover:bg-blue-700"
          >
            Go home
          </Link>
          <Link
            href="/blog"
            className="rounded border border-gray-300 px-6 py-2.5 text-sm font-medium text-gray-700 hover:bg-gray-50"
          >
            Browse articles
          </Link>
        </div>
      </div>
    </div>
  )
}

Route-specific error.tsx

You can also create error boundaries scoped to specific route segments. For example, a blog-specific error page:

TypeScript
// app/blog/error.tsx
'use client'

import { useEffect } from 'react'
import { isLynkowError } from 'lynkow'
import { captureError } from '@/lib/error-logging'

interface Props {
  error: Error & { digest?: string }
  reset: () => void
}

export default function BlogError({ error, reset }: Props) {
  useEffect(() => {
    captureError(error, { section: 'blog' })
  }, [error])

  const isTransient =
    isLynkowError(error) &&
    ['NETWORK_ERROR', 'TIMEOUT', 'SERVICE_UNAVAILABLE', 'RATE_LIMITED'].includes(error.code)

  return (
    <div className="mx-auto max-w-2xl py-16 text-center">
      <h2 className="mb-4 text-xl font-semibold text-gray-900">
        {isTransient ? 'Temporarily unavailable' : 'Error loading blog'}
      </h2>
      <p className="mb-6 text-gray-600">
        {isTransient
          ? 'The blog is temporarily unreachable. This usually resolves quickly.'
          : 'We encountered an error loading this page.'}
      </p>
      <button
        onClick={reset}
        className="rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
      >
        Try again
      </button>
    </div>
  )
}

Route-specific not-found.tsx

TypeScript
// app/blog/[slug]/not-found.tsx
import Link from 'next/link'

export default function ArticleNotFound() {
  return (
    <div className="mx-auto max-w-2xl py-16 text-center">
      <h2 className="mb-4 text-xl font-semibold text-gray-900">
        Article not found
      </h2>
      <p className="mb-6 text-gray-600">
        This article may have been removed or its URL may have changed.
      </p>
      <Link
        href="/blog"
        className="rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
      >
        Browse all articles
      </Link>
    </div>
  )
}

Summary

Scenario

Strategy

NOT_FOUND

Call notFound() in server components

VALIDATION_ERROR

Map details[].field to form inputs

RATE_LIMITED / TOO_MANY_REQUESTS

Exponential backoff with withRetry()

NETWORK_ERROR / TIMEOUT

Show fallback UI with retry button

UNAUTHORIZED

Fix siteId configuration

SERVICE_UNAVAILABLE

Retry after delay, show maintenance message

INTERNAL_ERROR

Log to Sentry, show generic error page

Any error in server component

Caught by nearest error.tsx

Any error in client component

Handle locally with try/catch + state