API Calls

Learn how to make API calls with and without authentication in Indie Kit

Making API Calls 🔌

Learn how to build powerful features by making API calls in your Indie Kit application! We'll cover public and protected endpoints with practical examples. ✨

Understanding API Routes 🎯

Indie Kit uses Next.js App Router API routes. All API endpoints live in the src/app/api/ directory:

src/app/api/
  products/route.ts          ← Public endpoint
  app/user-settings/route.ts ← Protected endpoint (requires auth)

Two Types of Endpoints

  • Public endpoints: Anyone can access (e.g., blog posts, products)
  • Protected endpoints: Require authentication (e.g., user settings, admin actions)

Public API Calls 🌐

Public endpoints don't require authentication. Use them for data that anyone can view.

Step 1: Create the API Endpoint

Create a new file at src/app/api/products/route.ts:

// src/app/api/products/route.ts
import { NextResponse } from 'next/server'

export async function GET() {
  // In a real app, fetch from your database
  const products = [
    { id: 1, name: 'Product 1', price: 99 },
    { id: 2, name: 'Product 2', price: 149 },
  ]
  
  return NextResponse.json(products)
}

Step 2: Fetch Data in Your Component

Use SWR to fetch data with automatic caching and revalidation:

// src/app/products/page.tsx
'use client'

import useSWR from 'swr'

export default function ProductsPage() {
  const { data, error, isLoading } = useSWR('/api/products')

  if (error) return <div>Failed to load products</div>
  if (isLoading) return <div>Loading...</div>

  return (
    <div>
      <h1>Products</h1>
      <ul>
        {data.map((product) => (
          <li key={product.id}>
            {product.name} - ${product.price}
          </li>
        ))}
      </ul>
    </div>
  )
}

What is SWR?

SWR (Stale-While-Revalidate) is a data fetching library that comes pre-configured in Indie Kit. It provides automatic caching, revalidation, and loading states out of the box!

Protected API Calls 🔒

Protected endpoints require authentication. Use them for user-specific data or admin actions.

Important

Always use withAuthRequired wrapper for protected endpoints. This ensures only authenticated users can access the data!

Step 1: Create Protected API Endpoint

Create a protected endpoint at src/app/api/app/user-settings/route.ts:

// src/app/api/app/user-settings/route.ts
import { withAuthRequired } from '@/lib/auth/withAuthRequired'
import { NextResponse } from 'next/server'
import { db } from '@/db'
import { users } from '@/db/schema'
import { eq } from 'drizzle-orm'

// GET user settings
export const GET = withAuthRequired(async (req, context) => {
  const { session } = context
  
  // Fetch settings from database
  const userSettings = await db
    .select()
    .from(users)
    .where(eq(users.id, session.user.id))
    .limit(1)
  
  return NextResponse.json({
    userId: session.user.id,
    theme: userSettings[0]?.theme || 'light',
    notifications: userSettings[0]?.notifications || true,
  })
})

// POST to update settings
export const POST = withAuthRequired(async (req, context) => {
  const { session } = context
  const body = await req.json()
  
  // Update in database
  await db
    .update(users)
    .set({ theme: body.theme })
    .where(eq(users.id, session.user.id))
  
  return NextResponse.json({ success: true })
})

Step 2: Fetch and Update Data

Use SWR for fetching and mutate for optimistic updates:

// src/app/settings/page.tsx
'use client'

import useSWR, { mutate } from 'swr'
import { toast } from 'sonner'

export default function SettingsPage() {
  const { data, error, isLoading } = useSWR('/api/app/user-settings')

  const updateSettings = async (newSettings: { theme: string }) => {
    try {
      // Optimistically update the UI
      mutate(
        '/api/app/user-settings',
        { ...data, ...newSettings },
        false
      )

      // Make the API call
      const response = await fetch('/api/app/user-settings', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(newSettings),
      })

      if (!response.ok) throw new Error('Failed to update')

      // Revalidate to get fresh data
      mutate('/api/app/user-settings')
      toast.success('Settings updated!')
    } catch (error) {
      // Rollback on error
      mutate('/api/app/user-settings')
      toast.error('Failed to update settings')
    }
  }

  if (error) return <div>Failed to load settings</div>
  if (isLoading) return <div>Loading...</div>

  return (
    <div>
      <h1>User Settings</h1>
      <div className="space-y-4">
        <div>
          <p>Current theme: {data.theme}</p>
          <button
            onClick={() => updateSettings({ 
              theme: data.theme === 'light' ? 'dark' : 'light' 
            })}
            className="px-4 py-2 bg-blue-500 text-white rounded"
          >
            Toggle Theme
          </button>
        </div>
      </div>
    </div>
  )
}

SWR Features & Patterns 🎯

SWR comes pre-configured in Indie Kit with powerful features:

Basic Fetching

const { data, error, isLoading } = useSWR('/api/endpoint')

Type-Safe Fetching

interface Product {
  id: number
  name: string
  price: number
}

const { data, error } = useSWR<Product[]>('/api/products')

Conditional Fetching

Only fetch when certain conditions are met:

// Only fetch when user is logged in
const { data } = useSWR(session ? '/api/app/profile' : null)

// Only fetch with valid ID
const { data } = useSWR(
  productId ? `/api/products/${productId}` : null
)

Revalidation

import { mutate } from 'swr'

// Revalidate specific endpoint
mutate('/api/products')

// Revalidate all endpoints matching pattern
mutate((key) => typeof key === 'string' && key.startsWith('/api/products'))

Optimistic Updates

Update the UI immediately, then sync with server:

const addProduct = async (newProduct) => {
  // Update UI optimistically
  mutate('/api/products', [...data, newProduct], false)
  
  // Make API call
  await fetch('/api/products', {
    method: 'POST',
    body: JSON.stringify(newProduct)
  })
  
  // Revalidate to sync with server
  mutate('/api/products')
}

Learn More

SWR has many more features! Check out the official documentation for pagination, infinite loading, and more.

Common Patterns 📚

Loading with Skeleton

import { Skeleton } from "@/components/ui/skeleton"

export default function ProductsPage() {
  const { data, isLoading } = useSWR('/api/products')

  if (isLoading) {
    return (
      <div className="space-y-4">
        <Skeleton className="h-12 w-full" />
        <Skeleton className="h-12 w-full" />
        <Skeleton className="h-12 w-full" />
      </div>
    )
  }

  return <ProductList products={data} />
}

Error Handling

export default function ProductsPage() {
  const { data, error, isLoading } = useSWR('/api/products')

  if (error) {
    return (
      <div className="p-4 bg-red-50 text-red-600 rounded">
        Failed to load products. Please try again later.
      </div>
    )
  }

  if (isLoading) return <div>Loading...</div>

  return <ProductList products={data} />
}

Mutation with Error Handling

const deleteProduct = async (id: number) => {
  try {
    // Optimistic update
    const newData = data.filter(p => p.id !== id)
    mutate('/api/products', newData, false)

    // Make API call
    const response = await fetch(`/api/products/${id}`, {
      method: 'DELETE'
    })

    if (!response.ok) throw new Error('Delete failed')

    toast.success('Product deleted!')
  } catch (error) {
    // Rollback on error
    mutate('/api/products')
    toast.error('Failed to delete product')
  }
}

HTTP Methods 🔄

Use the appropriate HTTP method for each action:

GET - Fetch Data

export async function GET() {
  const data = await fetchFromDatabase()
  return NextResponse.json(data)
}

POST - Create New Resource

export async function POST(req: Request) {
  const body = await req.json()
  const newItem = await createInDatabase(body)
  return NextResponse.json(newItem)
}

PUT - Update Entire Resource

export async function PUT(req: Request) {
  const body = await req.json()
  const updated = await updateInDatabase(body)
  return NextResponse.json(updated)
}

PATCH - Partial Update

export async function PATCH(req: Request) {
  const body = await req.json()
  const patched = await partialUpdateInDatabase(body)
  return NextResponse.json(patched)
}

DELETE - Remove Resource

export async function DELETE(req: Request) {
  await deleteFromDatabase()
  return NextResponse.json({ success: true })
}

Best Practices 💡

1. Always Handle Loading and Error States

const { data, error, isLoading } = useSWR('/api/endpoint')

if (error) return <ErrorMessage />
if (isLoading) return <LoadingSpinner />
return <DataDisplay data={data} />

2. Use TypeScript for Type Safety

interface User {
  id: string
  name: string
  email: string
}

const { data } = useSWR<User>('/api/app/profile')
// data is now typed as User

3. Implement Optimistic Updates

Make your app feel instant:

// Update UI first
mutate('/api/todos', [...todos, newTodo], false)
// Then make API call
await createTodo(newTodo)
// Finally revalidate
mutate('/api/todos')

4. Validate Input Server-Side

export const POST = withAuthRequired(async (req, context) => {
  const body = await req.json()
  
  // Validate input
  if (!body.name || body.name.length < 3) {
    return NextResponse.json(
      { error: 'Name must be at least 3 characters' },
      { status: 400 }
    )
  }
  
  // Process valid data
  // ...
})

5. Use Proper Error Status Codes

// 400 - Bad Request (invalid input)
return NextResponse.json({ error: 'Invalid data' }, { status: 400 })

// 401 - Unauthorized (not logged in)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })

// 403 - Forbidden (logged in but no permission)
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })

// 404 - Not Found
return NextResponse.json({ error: 'Not found' }, { status: 404 })

// 500 - Server Error
return NextResponse.json({ error: 'Server error' }, { status: 500 })

Security Checklist 🔐

  • ✅ Use withAuthRequired for protected endpoints
  • ✅ Validate all input data server-side
  • ✅ Never trust client-side data
  • ✅ Use proper HTTP methods
  • ✅ Return appropriate error codes
  • ✅ Don't expose sensitive data in error messages
  • ✅ Rate limit API endpoints if needed
  • ✅ Use HTTPS in production

Troubleshooting 🔧

"useSWR is not defined"

Make sure your component has 'use client' at the top:

'use client'

import useSWR from 'swr'

"401 Unauthorized" on Protected Routes

Ensure you're logged in and using withAuthRequired:

export const GET = withAuthRequired(async (req, context) => {
  // Your code here
})

Data Not Updating

Force revalidation after mutations:

await updateData()
mutate('/api/endpoint') // Add this

CORS Errors

Next.js API routes don't have CORS issues since they're same-origin. If calling external APIs, handle CORS server-side.

Next Steps 🚀

Now you know how to:

  • ✅ Create public and protected API endpoints
  • ✅ Fetch data with SWR
  • ✅ Handle loading and error states
  • ✅ Implement optimistic updates
  • ✅ Use proper HTTP methods
  • ✅ Secure your endpoints

Start Building

You're ready to build powerful features with API calls! Start with a simple endpoint and expand from there. 🎉

Resources 📚