Tutorial Next.js 15 TypeScript

Next.js Astrology App: Complete Tutorial with Vedika API (2026)

Build a production-ready astrology application from scratch using Next.js 15 App Router and Vedika API. This step-by-step guide covers Server Components, ISR for daily horoscopes, AI chat with streaming, birth chart forms, zodiac compatibility, SEO optimization for all 12 signs, and deployment to Vercel.

Published: February 9, 2026 | 18 min read
Next.js Astrology App Tutorial with Vedika API - Server Components, ISR, and AI Chat

Table of Contents

Why Next.js for Astrology Apps

Next.js is the strongest framework choice for astrology applications in 2026, and the reasons go beyond general full-stack convenience. Astrology apps have specific requirements that Next.js addresses better than any alternative.

SEO for Horoscope Content

Horoscope pages are among the most-searched content on the internet. "Aries horoscope today" gets millions of monthly searches. Server-side rendering means Google indexes your content immediately, unlike client-rendered React apps where crawlers see empty divs. Next.js metadata API lets you generate unique title tags and descriptions for each zodiac sign dynamically.

Server Components for API Security

Astrology API keys are paid credentials. With Server Components, your Vedika API key never leaves the server. The component fetches data on the server, renders HTML, and sends only the result to the browser. No NEXT_PUBLIC_ prefix needed, no key exposure in network tab, no client-side interception risk.

ISR for Daily Horoscopes

Daily horoscopes change once per day but get viewed thousands of times. Incremental Static Regeneration (ISR) with revalidate: 86400 calls Vedika API once, then serves the cached page to every visitor for 24 hours. This slashes API costs from thousands of calls to just 12 per day (one per sign).

Route Handlers Replace Express

Instead of maintaining a separate Express backend to proxy Vedika API calls, Next.js Route Handlers (app/api/*/route.ts) run server-side functions alongside your frontend. One codebase, one deployment, one Vercel project. Streaming responses for AI chat work natively through Route Handlers.

What We Are Building

A full-stack astrology app with: daily horoscope pages for all 12 zodiac signs (ISR-cached), a birth chart generator with form input, an AI astrology chatbot with real-time streaming, a zodiac compatibility checker, and full SEO optimization. All powered by Vedika API and deployed to Vercel. Total estimated build time: 2-3 hours.

Project Setup

Start by creating a new Next.js 15 project with TypeScript and the App Router. We will use Tailwind CSS for styling, which comes pre-configured with create-next-app.

Terminal
npx create-next-app@latest astrology-app --typescript --tailwind --app --src-dir
cd astrology-app

# Install Vedika SDK
npm install @vedika-io/sdk

# Optional: Helpful utilities
npm install date-fns zod

Next, create a .env.local file in the project root for your Vedika API key. You can get a key from your Vedika Dashboard in seconds.

.env.local
# Server-only (no NEXT_PUBLIC_ prefix = never sent to browser)
VEDIKA_API_KEY=vk_live_your_api_key_here
VEDIKA_API_BASE=https://api.vedika.io

Security note: Never prefix your API key with NEXT_PUBLIC_. Variables without this prefix are only accessible in Server Components and Route Handlers, keeping your Vedika API key completely hidden from the browser.

Create a shared Vedika API client that we will reuse across all server-side code. This file should live in src/lib/:

src/lib/vedika.ts
import { VedikaClient } from '@vedika-io/sdk';

// Singleton client instance (server-only)
let client: VedikaClient | null = null;

export function getVedikaClient(): VedikaClient {
  if (!client) {
    const apiKey = process.env.VEDIKA_API_KEY;
    if (!apiKey) {
      throw new Error('VEDIKA_API_KEY environment variable is required');
    }
    client = new VedikaClient(apiKey, {
      baseUrl: process.env.VEDIKA_API_BASE || 'https://api.vedika.io',
    });
  }
  return client;
}

// Zodiac sign types used across the app
export const ZODIAC_SIGNS = [
  'aries', 'taurus', 'gemini', 'cancer',
  'leo', 'virgo', 'libra', 'scorpio',
  'sagittarius', 'capricorn', 'aquarius', 'pisces',
] as const;

export type ZodiacSign = (typeof ZODIAC_SIGNS)[number];

Your project structure should now look like this:

astrology-app/
  src/
    app/
      api/              # Route Handlers (proxy to Vedika)
      horoscope/[sign]/ # Daily horoscope (ISR)
      birth-chart/      # Birth chart generator
      chat/             # AI astrology chat
      compatibility/    # Zodiac compatibility
      layout.tsx        # Root layout
      page.tsx          # Home page
    lib/
      vedika.ts         # API client singleton
    components/         # Shared UI components
  .env.local            # API key (git-ignored)

1API Route Handler for Vedika API

Route Handlers are the backbone of your app's server-side communication with Vedika API. They run exclusively on the server, so your API key stays secret. Client Components will call these Route Handlers instead of the Vedika API directly.

Create a general-purpose predictions Route Handler that wraps the Vedika V2 daily prediction endpoint ($0.02/call):

src/app/api/horoscope/route.ts
import { NextRequest, NextResponse } from 'next/server';

const VEDIKA_API_BASE = process.env.VEDIKA_API_BASE || 'https://api.vedika.io';
const VEDIKA_API_KEY = process.env.VEDIKA_API_KEY!;

export async function GET(request: NextRequest) {
  const sign = request.nextUrl.searchParams.get('sign');

  if (!sign) {
    return NextResponse.json(
      { error: 'Missing sign parameter' },
      { status: 400 }
    );
  }

  try {
    const response = await fetch(
      `${VEDIKA_API_BASE}/v2/astrology/prediction/daily`,
      {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'x-api-key': `Bearer ${VEDIKA_API_KEY}`,
        },
        body: JSON.stringify({ sign }),
        // Cache on the server for 24 hours
        next: { revalidate: 86400 },
      }
    );

    if (!response.ok) {
      throw new Error(`Vedika API error: ${response.status}`);
    }

    const data = await response.json();
    return NextResponse.json(data);
  } catch (error) {
    console.error('Horoscope API error:', error);
    return NextResponse.json(
      { error: 'Failed to fetch horoscope' },
      { status: 500 }
    );
  }
}

And a Route Handler for birth chart generation, wrapping the /api/vedika/birth-chart endpoint:

src/app/api/birth-chart/route.ts
import { NextRequest, NextResponse } from 'next/server';

const VEDIKA_API_BASE = process.env.VEDIKA_API_BASE || 'https://api.vedika.io';
const VEDIKA_API_KEY = process.env.VEDIKA_API_KEY!;

export async function POST(request: NextRequest) {
  const body = await request.json();
  const { datetime, latitude, longitude, timezone } = body;

  // Validate required fields
  if (!datetime || !latitude || !longitude || !timezone) {
    return NextResponse.json(
      { error: 'Missing required birth details' },
      { status: 400 }
    );
  }

  try {
    const response = await fetch(
      `${VEDIKA_API_BASE}/api/vedika/birth-chart`,
      {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'x-api-key': `Bearer ${VEDIKA_API_KEY}`,
        },
        body: JSON.stringify({
          birthDetails: { datetime, latitude, longitude, timezone },
        }),
      }
    );

    if (!response.ok) {
      throw new Error(`Vedika API error: ${response.status}`);
    }

    const data = await response.json();
    return NextResponse.json(data);
  } catch (error) {
    console.error('Birth chart API error:', error);
    return NextResponse.json(
      { error: 'Failed to generate birth chart' },
      { status: 500 }
    );
  }
}

Why Route Handlers? Your Vedika API key (vk_live_*) costs money per call. If you called the API directly from a Client Component, anyone could open DevTools, copy the key from the network tab, and use your credits. Route Handlers ensure the key only exists on the server.

2Daily Horoscope Page with ISR

This is where Next.js truly shines for astrology. A daily horoscope for Aries is the same for every visitor on a given day. Instead of calling Vedika API on every page load, we use ISR to call it once and cache the result for 24 hours. When the cache expires, Next.js regenerates the page in the background while still serving the stale version instantly.

The economics are compelling: without ISR, 10,000 daily visitors across 12 signs would cost 120,000 API calls. With ISR, that same traffic costs exactly 12 calls per day. At $0.02/call, that is $0.24/day instead of $2,400/day.

src/app/horoscope/[sign]/page.tsx
import { notFound } from 'next/navigation';
import { ZODIAC_SIGNS, type ZodiacSign } from '@/lib/vedika';
import type { Metadata } from 'next';

interface HoroscopeData {
  sign: string;
  date: string;
  prediction: string;
  luckyNumber: number;
  luckyColor: string;
  mood: string;
  compatibility: string;
}

// Revalidate every 24 hours (86400 seconds)
export const revalidate = 86400;

// Pre-render all 12 zodiac sign pages at build time
export function generateStaticParams() {
  return ZODIAC_SIGNS.map((sign) => ({ sign }));
}

// Dynamic SEO metadata for each sign
export function generateMetadata({
  params,
}: {
  params: { sign: string };
}): Metadata {
  const sign = params.sign as ZodiacSign;
  const capitalized = sign.charAt(0).toUpperCase() + sign.slice(1);

  return {
    title: `${capitalized} Horoscope Today - Daily Prediction | Astrology App`,
    description: `Read today's ${capitalized} horoscope. Daily prediction for ${capitalized} zodiac sign with lucky number, color, mood, and compatibility.`,
    openGraph: {
      title: `${capitalized} Daily Horoscope`,
      description: `Today's astrological prediction for ${capitalized}`,
    },
  };
}

async function getHoroscope(sign: string): Promise<HoroscopeData> {
  const apiBase = process.env.VEDIKA_API_BASE || 'https://api.vedika.io';
  const apiKey = process.env.VEDIKA_API_KEY!;

  const res = await fetch(`${apiBase}/v2/astrology/prediction/daily`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'x-api-key': `Bearer ${apiKey}`,
    },
    body: JSON.stringify({ sign }),
    next: { revalidate: 86400 },
  });

  if (!res.ok) {
    throw new Error('Failed to fetch horoscope');
  }

  return res.json();
}

export default async function HoroscopePage({
  params,
}: {
  params: { sign: string };
}) {
  const { sign } = params;

  // Validate the sign
  if (!ZODIAC_SIGNS.includes(sign as ZodiacSign)) {
    notFound();
  }

  const horoscope = await getHoroscope(sign);
  const capitalized = sign.charAt(0).toUpperCase() + sign.slice(1);

  return (
    <main className="max-w-2xl mx-auto px-4 py-12">
      <h1 className="text-4xl font-bold mb-2">
        {capitalized} Horoscope Today
      </h1>
      <p className="text-gray-500 mb-8">{horoscope.date}</p>

      <div className="bg-white rounded-xl shadow-lg p-8 mb-6">
        <p className="text-lg text-gray-700 leading-relaxed">
          {horoscope.prediction}
        </p>
      </div>

      <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
        <div className="bg-indigo-50 rounded-lg p-4 text-center">
          <p className="text-sm text-gray-500">Lucky Number</p>
          <p className="text-2xl font-bold text-indigo-600">
            {horoscope.luckyNumber}
          </p>
        </div>
        <div className="bg-purple-50 rounded-lg p-4 text-center">
          <p className="text-sm text-gray-500">Lucky Color</p>
          <p className="text-lg font-semibold text-purple-600">
            {horoscope.luckyColor}
          </p>
        </div>
        <div className="bg-green-50 rounded-lg p-4 text-center">
          <p className="text-sm text-gray-500">Mood</p>
          <p className="text-lg font-semibold text-green-600">
            {horoscope.mood}
          </p>
        </div>
        <div className="bg-pink-50 rounded-lg p-4 text-center">
          <p className="text-sm text-gray-500">Compatible With</p>
          <p className="text-lg font-semibold text-pink-600">
            {horoscope.compatibility}
          </p>
        </div>
      </div>
    </main>
  );
}

How ISR works here: On the first request for /horoscope/aries, Next.js calls Vedika API, renders the page, and caches it. For the next 86,400 seconds (24 hours), every visitor gets the cached version instantly. When the cache expires, the next visitor still gets the stale cache while Next.js regenerates the page in the background. Zero downtime, minimal API cost.

3Birth Chart Input Form (Client Component)

Birth chart generation requires user input (date, time, location), so this must be a Client Component. The form collects birth details, sends them to our Route Handler (not directly to Vedika API), and displays the results. We use the "use client" directive.

src/app/birth-chart/BirthChartForm.tsx
'use client';

import { useState, type FormEvent } from 'react';

interface BirthChartResult {
  ascendant: { sign: string; degree: number };
  moonSign: string;
  sunSign: string;
  planets: Record<string, {
    sign: string;
    house: number;
    degree: number;
    isRetrograde: boolean;
  }>;
  nakshatras: { moon: string; sun: string };
}

export default function BirthChartForm() {
  const [result, setResult] = useState<BirthChartResult | null>(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  async function handleSubmit(e: FormEvent<HTMLFormElement>) {
    e.preventDefault();
    setLoading(true);
    setError(null);

    const formData = new FormData(e.currentTarget);

    try {
      const response = await fetch('/api/birth-chart', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          datetime: `${formData.get('date')}T${formData.get('time')}:00`,
          latitude: parseFloat(formData.get('latitude') as string),
          longitude: parseFloat(formData.get('longitude') as string),
          timezone: formData.get('timezone') as string,
        }),
      });

      if (!response.ok) throw new Error('Failed to generate chart');

      const data = await response.json();
      setResult(data);
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Something went wrong');
    } finally {
      setLoading(false);
    }
  }

  return (
    <div className="max-w-xl mx-auto">
      <form onSubmit={handleSubmit} className="space-y-4">
        <div className="grid grid-cols-2 gap-4">
          <div>
            <label className="block text-sm font-medium mb-1">
              Birth Date
            </label>
            <input
              type="date"
              name="date"
              required
              className="w-full border rounded-lg p-2"
            />
          </div>
          <div>
            <label className="block text-sm font-medium mb-1">
              Birth Time
            </label>
            <input
              type="time"
              name="time"
              required
              className="w-full border rounded-lg p-2"
            />
          </div>
        </div>

        <div className="grid grid-cols-2 gap-4">
          <div>
            <label className="block text-sm font-medium mb-1">
              Latitude
            </label>
            <input
              type="number"
              name="latitude"
              step="0.0001"
              placeholder="19.0760"
              required
              className="w-full border rounded-lg p-2"
            />
          </div>
          <div>
            <label className="block text-sm font-medium mb-1">
              Longitude
            </label>
            <input
              type="number"
              name="longitude"
              step="0.0001"
              placeholder="72.8777"
              required
              className="w-full border rounded-lg p-2"
            />
          </div>
        </div>

        <div>
          <label className="block text-sm font-medium mb-1">
            Timezone Offset
          </label>
          <select
            name="timezone"
            required
            className="w-full border rounded-lg p-2"
          >
            <option value="+05:30">IST (+05:30)</option>
            <option value="+00:00">UTC (+00:00)</option>
            <option value="-05:00">EST (-05:00)</option>
            <option value="-08:00">PST (-08:00)</option>
            <option value="+01:00">CET (+01:00)</option>
            <option value="+09:00">JST (+09:00)</option>
          </select>
        </div>

        <button
          type="submit"
          disabled={loading}
          className="w-full bg-indigo-600 text-white py-3 rounded-lg
                     font-semibold hover:bg-indigo-700 disabled:opacity-50"
        >
          {loading ? 'Generating Chart...' : 'Generate Birth Chart'}
        </button>
      </form>

      {error && (
        <div className="mt-4 p-4 bg-red-50 text-red-700 rounded-lg">
          {error}
        </div>
      )}

      {result && (
        <div className="mt-8 bg-white rounded-xl shadow-lg p-6">
          <h2 className="text-xl font-bold mb-4">Your Birth Chart</h2>
          <div className="grid grid-cols-3 gap-4 mb-6">
            <div className="text-center p-3 bg-indigo-50 rounded-lg">
              <p className="text-sm text-gray-500">Ascendant</p>
              <p className="font-bold">{result.ascendant.sign}</p>
              <p className="text-xs">{result.ascendant.degree.toFixed(2)}</p>
            </div>
            <div className="text-center p-3 bg-purple-50 rounded-lg">
              <p className="text-sm text-gray-500">Moon Sign</p>
              <p className="font-bold">{result.moonSign}</p>
            </div>
            <div className="text-center p-3 bg-yellow-50 rounded-lg">
              <p className="text-sm text-gray-500">Sun Sign</p>
              <p className="font-bold">{result.sunSign}</p>
            </div>
          </div>

          <h3 className="font-semibold mb-2">Planetary Positions</h3>
          <div className="space-y-2">
            {Object.entries(result.planets).map(([planet, data]) => (
              <div
                key={planet}
                className="flex justify-between items-center
                           p-2 bg-gray-50 rounded"
              >
                <span className="font-medium capitalize">
                  {planet} {data.isRetrograde && '(R)'}
                </span>
                <span className="text-gray-600">
                  {data.sign} - House {data.house} -
                  {data.degree.toFixed(2)}&deg;
                </span>
              </div>
            ))}
          </div>
        </div>
      )}
    </div>
  );
}

Then render the form in a Server Component page that provides the layout and metadata:

src/app/birth-chart/page.tsx
import type { Metadata } from 'next';
import BirthChartForm from './BirthChartForm';

export const metadata: Metadata = {
  title: 'Free Birth Chart Generator - Vedic Astrology | Astrology App',
  description:
    'Generate your Vedic birth chart with Swiss Ephemeris accuracy. ' +
    'Enter your birth date, time, and location for planetary positions, ' +
    'ascendant, moon sign, and nakshatra analysis.',
};

export default function BirthChartPage() {
  return (
    <main className="min-h-screen bg-gray-50 py-12 px-4">
      <div className="max-w-2xl mx-auto text-center mb-8">
        <h1 className="text-4xl font-bold mb-3">
          Birth Chart Generator
        </h1>
        <p className="text-gray-600">
          Enter your birth details to generate a Vedic birth chart
          with Swiss Ephemeris accuracy. Planetary positions calculated
          to 0.01&deg; precision.
        </p>
      </div>
      <BirthChartForm />
    </main>
  );
}

4AI Astrology Chat with Streaming

This is the most impressive feature you can build. Vedika AI provides personalized astrology predictions through the /api/vedika/chat endpoint. Users ask natural language questions ("When will I get promoted?" or "What does Jupiter in my 10th house mean?") and receive detailed, chart-specific AI responses. The streaming mode delivers text word-by-word, like a chat interface.

First, create a Route Handler that proxies the streaming response from Vedika API:

src/app/api/chat/route.ts
import { NextRequest } from 'next/server';

const VEDIKA_API_BASE = process.env.VEDIKA_API_BASE || 'https://api.vedika.io';
const VEDIKA_API_KEY = process.env.VEDIKA_API_KEY!;

export async function POST(request: NextRequest) {
  const { question, birthDetails } = await request.json();

  if (!question || !birthDetails) {
    return new Response(
      JSON.stringify({ error: 'Missing question or birthDetails' }),
      { status: 400, headers: { 'Content-Type': 'application/json' } }
    );
  }

  try {
    const response = await fetch(`${VEDIKA_API_BASE}/api/vedika/chat`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'x-api-key': `Bearer ${VEDIKA_API_KEY}`,
      },
      body: JSON.stringify({
        question,
        birthDetails,
        stream: true,
        language: 'en',
      }),
    });

    if (!response.ok) {
      throw new Error(`Vedika API error: ${response.status}`);
    }

    // Forward the SSE stream directly to the client
    return new Response(response.body, {
      headers: {
        'Content-Type': 'text/event-stream',
        'Cache-Control': 'no-cache',
        Connection: 'keep-alive',
      },
    });
  } catch (error) {
    console.error('Chat API error:', error);
    return new Response(
      JSON.stringify({ error: 'Failed to connect to Vedika AI' }),
      { status: 500, headers: { 'Content-Type': 'application/json' } }
    );
  }
}

Now build the Client Component chat interface. This reads the Server-Sent Events stream and renders each chunk as it arrives:

src/app/chat/AstrologyChat.tsx
'use client';

import { useState, useRef, type FormEvent } from 'react';

interface Message {
  role: 'user' | 'assistant';
  content: string;
}

interface BirthDetails {
  datetime: string;
  latitude: number;
  longitude: number;
  timezone: string;
}

export default function AstrologyChat({
  birthDetails,
}: {
  birthDetails: BirthDetails;
}) {
  const [messages, setMessages] = useState<Message[]>([]);
  const [input, setInput] = useState('');
  const [isStreaming, setIsStreaming] = useState(false);
  const abortRef = useRef<AbortController | null>(null);

  async function handleSubmit(e: FormEvent) {
    e.preventDefault();
    if (!input.trim() || isStreaming) return;

    const userMessage = input.trim();
    setInput('');
    setMessages((prev) => [...prev, { role: 'user', content: userMessage }]);
    setIsStreaming(true);

    // Add empty assistant message that we will stream into
    setMessages((prev) => [...prev, { role: 'assistant', content: '' }]);

    try {
      abortRef.current = new AbortController();

      const response = await fetch('/api/chat', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          question: userMessage,
          birthDetails,
        }),
        signal: abortRef.current.signal,
      });

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

      const reader = response.body?.getReader();
      const decoder = new TextDecoder();

      if (!reader) throw new Error('No reader available');

      while (true) {
        const { done, value } = await reader.read();
        if (done) break;

        const chunk = decoder.decode(value, { stream: true });

        // Parse SSE events
        const lines = chunk.split('\n');
        for (const line of lines) {
          if (line.startsWith('data: ')) {
            const data = line.slice(6);
            if (data === '[DONE]') break;

            try {
              const parsed = JSON.parse(data);
              const text = parsed.content || parsed.text || '';

              // Append to the last assistant message
              setMessages((prev) => {
                const updated = [...prev];
                const last = updated[updated.length - 1];
                if (last.role === 'assistant') {
                  last.content += text;
                }
                return updated;
              });
            } catch {
              // Non-JSON chunk, append as raw text
              setMessages((prev) => {
                const updated = [...prev];
                const last = updated[updated.length - 1];
                if (last.role === 'assistant') {
                  last.content += data;
                }
                return updated;
              });
            }
          }
        }
      }
    } catch (err) {
      if ((err as Error).name !== 'AbortError') {
        setMessages((prev) => {
          const updated = [...prev];
          const last = updated[updated.length - 1];
          if (last.role === 'assistant' && !last.content) {
            last.content = 'Failed to get response. Please try again.';
          }
          return updated;
        });
      }
    } finally {
      setIsStreaming(false);
    }
  }

  return (
    <div className="flex flex-col h-[600px] bg-white
                    rounded-xl shadow-lg overflow-hidden">
      {/* Messages */}
      <div className="flex-1 overflow-y-auto p-4 space-y-4">
        {messages.length === 0 && (
          <div className="text-center text-gray-400 mt-20">
            <p className="text-lg">Ask anything about your birth chart</p>
            <p className="text-sm mt-2">
              Try: "What career suits my chart?" or
              "When is my next favorable period?"
            </p>
          </div>
        )}
        {messages.map((msg, i) => (
          <div
            key={i}
            className={`flex ${
              msg.role === 'user' ? 'justify-end' : 'justify-start'
            }`}
          >
            <div
              className={`max-w-[80%] p-3 rounded-lg ${
                msg.role === 'user'
                  ? 'bg-indigo-600 text-white'
                  : 'bg-gray-100 text-gray-800'
              }`}
            >
              {msg.content}
              {msg.role === 'assistant' &&
                isStreaming &&
                i === messages.length - 1 && (
                  <span className="animate-pulse">|</span>
                )}
            </div>
          </div>
        ))}
      </div>

      {/* Input */}
      <form
        onSubmit={handleSubmit}
        className="border-t p-4 flex gap-2"
      >
        <input
          type="text"
          value={input}
          onChange={(e) => setInput(e.target.value)}
          placeholder="Ask about your birth chart..."
          className="flex-1 border rounded-lg px-4 py-2
                     focus:outline-none focus:ring-2
                     focus:ring-indigo-500"
          disabled={isStreaming}
        />
        <button
          type="submit"
          disabled={isStreaming || !input.trim()}
          className="bg-indigo-600 text-white px-6 py-2 rounded-lg
                     font-medium hover:bg-indigo-700
                     disabled:opacity-50"
        >
          {isStreaming ? 'Thinking...' : 'Ask'}
        </button>
      </form>
    </div>
  );
}

Streaming architecture: The data flow is: Client Component → your Route Handler (/api/chat) → Vedika API (/api/vedika/chat). Your Route Handler forwards the SSE stream from Vedika directly to the client using Response(response.body). The API key stays on the server. The client reads the stream with ReadableStream and parses SSE events (data: lines) to update the UI in real time.

5Zodiac Compatibility Page

Compatibility checks are one of the highest-traffic features on any astrology platform. We will build a page where users select two zodiac signs and get a compatibility reading powered by Vedika AI. This uses the AI chat endpoint with a compatibility-focused question.

src/app/compatibility/CompatibilityChecker.tsx
'use client';

import { useState } from 'react';
import { ZODIAC_SIGNS } from '@/lib/vedika';

interface CompatibilityResult {
  score: number;
  summary: string;
  strengths: string[];
  challenges: string[];
}

export default function CompatibilityChecker() {
  const [sign1, setSign1] = useState('');
  const [sign2, setSign2] = useState('');
  const [result, setResult] = useState<CompatibilityResult | null>(null);
  const [loading, setLoading] = useState(false);

  async function checkCompatibility() {
    if (!sign1 || !sign2) return;
    setLoading(true);

    try {
      const res = await fetch('/api/chat', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          question: `Analyze the zodiac compatibility between ${sign1} and ${sign2}. Provide a compatibility score from 1-100, a brief summary, top 3 strengths, and top 3 challenges of this pairing. Respond in JSON format with keys: score, summary, strengths (array), challenges (array).`,
          birthDetails: {
            datetime: '2000-01-01T12:00:00',
            latitude: 0,
            longitude: 0,
            timezone: '+00:00',
          },
        }),
      });

      const data = await res.json();
      setResult(data);
    } catch {
      setResult(null);
    } finally {
      setLoading(false);
    }
  }

  const capitalize = (s: string) =>
    s.charAt(0).toUpperCase() + s.slice(1);

  return (
    <div className="max-w-lg mx-auto">
      <div className="grid grid-cols-2 gap-4 mb-6">
        <div>
          <label className="block text-sm font-medium mb-1">
            Your Sign
          </label>
          <select
            value={sign1}
            onChange={(e) => setSign1(e.target.value)}
            className="w-full border rounded-lg p-3"
          >
            <option value="">Select sign...</option>
            {ZODIAC_SIGNS.map((s) => (
              <option key={s} value={s}>{capitalize(s)}</option>
            ))}
          </select>
        </div>
        <div>
          <label className="block text-sm font-medium mb-1">
            Partner's Sign
          </label>
          <select
            value={sign2}
            onChange={(e) => setSign2(e.target.value)}
            className="w-full border rounded-lg p-3"
          >
            <option value="">Select sign...</option>
            {ZODIAC_SIGNS.map((s) => (
              <option key={s} value={s}>{capitalize(s)}</option>
            ))}
          </select>
        </div>
      </div>

      <button
        onClick={checkCompatibility}
        disabled={!sign1 || !sign2 || loading}
        className="w-full bg-pink-600 text-white py-3 rounded-lg
                   font-semibold hover:bg-pink-700 disabled:opacity-50"
      >
        {loading ? 'Analyzing...' : 'Check Compatibility'}
      </button>

      {result && (
        <div className="mt-6 bg-white rounded-xl shadow-lg p-6">
          <div className="text-center mb-4">
            <p className="text-5xl font-bold text-pink-600">
              {result.score}%
            </p>
            <p className="text-gray-500">Compatibility Score</p>
          </div>
          <p className="text-gray-700 mb-4">{result.summary}</p>
          <div className="grid grid-cols-2 gap-4">
            <div>
              <h4 className="font-semibold text-green-700 mb-2">
                Strengths
              </h4>
              <ul className="text-sm text-gray-600 space-y-1">
                {result.strengths.map((s, i) => (
                  <li key={i}>+ {s}</li>
                ))}
              </ul>
            </div>
            <div>
              <h4 className="font-semibold text-red-700 mb-2">
                Challenges
              </h4>
              <ul className="text-sm text-gray-600 space-y-1">
                {result.challenges.map((c, i) => (
                  <li key={i}>- {c}</li>
                ))}
              </ul>
            </div>
          </div>
        </div>
      )}
    </div>
  );
}

6SEO Optimization

Astrology content is extremely SEO-competitive. Keywords like "Aries horoscope today" get millions of monthly searches. Next.js gives you the tools to compete, but you need to use them correctly. Here is a complete SEO setup.

Root Layout with Global Metadata

src/app/layout.tsx
import type { Metadata } from 'next';
import './globals.css';

export const metadata: Metadata = {
  metadataBase: new URL('https://your-astrology-app.com'),
  title: {
    template: '%s | AstrologyApp',
    default: 'AstrologyApp - AI Astrology Predictions & Birth Charts',
  },
  description:
    'AI-powered astrology predictions, daily horoscopes, Vedic birth charts, ' +
    'and zodiac compatibility. Swiss Ephemeris accuracy with 30 language support.',
  openGraph: {
    type: 'website',
    locale: 'en_US',
    siteName: 'AstrologyApp',
  },
  twitter: {
    card: 'summary_large_image',
  },
  robots: {
    index: true,
    follow: true,
    googleBot: {
      index: true,
      follow: true,
      'max-image-preview': 'large',
    },
  },
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  );
}

generateStaticParams for All 12 Signs

The generateStaticParams function we defined in Step 2 pre-renders pages for all 12 zodiac signs at build time. Each page gets its own URL (/horoscope/aries, /horoscope/taurus, etc.), its own metadata, and its own cached content. Google can crawl and index all 12 pages independently.

Sitemap Generation

src/app/sitemap.ts
import { ZODIAC_SIGNS } from '@/lib/vedika';
import type { MetadataRoute } from 'next';

export default function sitemap(): MetadataRoute.Sitemap {
  const baseUrl = 'https://your-astrology-app.com';

  // Static pages
  const staticPages = [
    { url: baseUrl, changeFrequency: 'daily' as const, priority: 1.0 },
    { url: `${baseUrl}/birth-chart`, changeFrequency: 'monthly' as const, priority: 0.8 },
    { url: `${baseUrl}/chat`, changeFrequency: 'monthly' as const, priority: 0.8 },
    { url: `${baseUrl}/compatibility`, changeFrequency: 'monthly' as const, priority: 0.7 },
  ];

  // Dynamic horoscope pages (12 signs)
  const horoscopePages = ZODIAC_SIGNS.map((sign) => ({
    url: `${baseUrl}/horoscope/${sign}`,
    changeFrequency: 'daily' as const,
    priority: 0.9,
    lastModified: new Date(),
  }));

  return [...staticPages, ...horoscopePages];
}

JSON-LD Structured Data

Add structured data to each horoscope page for rich search results. Add this to the horoscope page component:

JSON-LD snippet for horoscope pages
// Add inside HoroscopePage component, before the return
const jsonLd = {
  '@context': 'https://schema.org',
  '@type': 'Article',
  headline: `${capitalized} Horoscope Today`,
  datePublished: horoscope.date,
  dateModified: horoscope.date,
  author: {
    '@type': 'Organization',
    name: 'AstrologyApp',
  },
  speakable: {
    '@type': 'SpeakableSpecification',
    cssSelector: ['h1', '.prediction-text'],
  },
};

// Add in the return JSX, inside <main>:
<script
  type="application/ld+json"
  dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>

7Deploy to Vercel

Vercel is the natural deployment target for Next.js, and deploying an astrology app there takes about 2 minutes. ISR and Route Handlers work out of the box with zero configuration.

Terminal
# Install Vercel CLI
npm i -g vercel

# Deploy (first time - follow prompts to link project)
vercel

# Set environment variable on Vercel
vercel env add VEDIKA_API_KEY
# Paste your vk_live_* key when prompted

vercel env add VEDIKA_API_BASE
# Enter: https://api.vedika.io

# Deploy to production
vercel --prod

Post-deploy checklist

  • Verify environment variables are set in Vercel dashboard → Settings → Environment Variables
  • Test /horoscope/aries loads with ISR (check response headers for x-vercel-cache: HIT)
  • Test /birth-chart form submits and returns planetary data
  • Test /chat streaming (text should appear word-by-word)
  • Open DevTools Network tab and confirm VEDIKA_API_KEY never appears in any client request
  • Submit sitemap at https://your-domain.com/sitemap.xml to Google Search Console
  • Run Lighthouse audit and confirm SEO score is 95+

Cost estimate: Vercel free tier gives you 100GB bandwidth and serverless function invocations. Vedika Starter plan ($12/mo) covers the API calls. With ISR, 12 daily horoscope calls cost $0.24/day ($7.20/mo). A typical astrology app with moderate traffic runs well under $20/month total infrastructure cost.

Advanced: Caching Strategy, Rate Limiting, Error Boundaries

Multi-layer Caching Strategy

A production astrology app needs caching at multiple levels. ISR handles page-level caching, but you also want request-level caching for API responses and client-level caching to prevent duplicate requests.

src/lib/cache.ts
// In-memory cache for serverless functions
const cache = new Map<string, { data: unknown; expires: number }>();

export function getCached<T>(key: string): T | null {
  const entry = cache.get(key);
  if (!entry) return null;
  if (Date.now() > entry.expires) {
    cache.delete(key);
    return null;
  }
  return entry.data as T;
}

export function setCache(key: string, data: unknown, ttlSeconds: number) {
  cache.set(key, {
    data,
    expires: Date.now() + ttlSeconds * 1000,
  });
}

// Usage in Route Handlers:
// const cacheKey = `horoscope:${sign}:${today}`;
// const cached = getCached<HoroscopeData>(cacheKey);
// if (cached) return NextResponse.json(cached);
// ... fetch from Vedika API ...
// setCache(cacheKey, data, 86400);

Rate Limiting Client Requests

Protect your API budget by rate limiting requests at the Route Handler level. This prevents any single client from making excessive calls to your Vedika-backed endpoints:

src/lib/rateLimit.ts
const rateLimitMap = new Map<string, { count: number; resetTime: number }>();

export function rateLimit(
  ip: string,
  maxRequests: number = 10,
  windowMs: number = 60000
): boolean {
  const now = Date.now();
  const entry = rateLimitMap.get(ip);

  if (!entry || now > entry.resetTime) {
    rateLimitMap.set(ip, { count: 1, resetTime: now + windowMs });
    return true; // Allowed
  }

  if (entry.count >= maxRequests) {
    return false; // Blocked
  }

  entry.count++;
  return true; // Allowed
}

// Usage in Route Handler:
// const ip = request.headers.get('x-forwarded-for') || 'unknown';
// if (!rateLimit(ip, 20, 60000)) {
//   return NextResponse.json(
//     { error: 'Too many requests' },
//     { status: 429 }
//   );
// }

Error Boundary for Graceful Failures

If the Vedika API is temporarily unreachable, you do not want your entire page to crash. Next.js supports error.tsx files at each route level:

src/app/horoscope/[sign]/error.tsx
'use client';

export default function HoroscopeError({
  error,
  reset,
}: {
  error: Error;
  reset: () => void;
}) {
  return (
    <div className="max-w-xl mx-auto py-20 text-center">
      <h2 className="text-2xl font-bold text-gray-900 mb-4">
        Horoscope Temporarily Unavailable
      </h2>
      <p className="text-gray-600 mb-6">
        We could not load today's horoscope. This usually resolves
        within a few minutes.
      </p>
      <button
        onClick={reset}
        className="bg-indigo-600 text-white px-6 py-3
                   rounded-lg font-semibold hover:bg-indigo-700"
      >
        Try Again
      </button>
    </div>
  );
}

Similarly, add a loading.tsx for the horoscope pages to show a skeleton while ISR regeneration occurs:

src/app/horoscope/[sign]/loading.tsx
export default function HoroscopeLoading() {
  return (
    <div className="max-w-2xl mx-auto px-4 py-12 animate-pulse">
      <div className="h-10 bg-gray-200 rounded w-3/4 mb-4" />
      <div className="h-4 bg-gray-200 rounded w-1/4 mb-8" />
      <div className="bg-white rounded-xl shadow-lg p-8 mb-6">
        <div className="space-y-3">
          <div className="h-4 bg-gray-200 rounded w-full" />
          <div className="h-4 bg-gray-200 rounded w-5/6" />
          <div className="h-4 bg-gray-200 rounded w-4/6" />
          <div className="h-4 bg-gray-200 rounded w-full" />
        </div>
      </div>
      <div className="grid grid-cols-4 gap-4">
        {[1, 2, 3, 4].map((i) => (
          <div key={i} className="h-20 bg-gray-200 rounded-lg" />
        ))}
      </div>
    </div>
  );
}

Production Architecture Summary

Feature Pattern Vedika Endpoint Caching
Daily Horoscope Server Component + ISR /v2/astrology/prediction/daily 86,400s (24h)
Birth Chart Client Component + Route Handler /api/vedika/birth-chart No cache (unique per user)
AI Chat Client Component + Streaming Route Handler /api/vedika/chat No cache (real-time)
Compatibility Client Component + Route Handler /api/vedika/chat Optional (per sign pair)

Frequently Asked Questions

Can I use Next.js Server Components with an astrology API?

Yes. Next.js Server Components are ideal for astrology APIs because they run on the server, keeping your API key secret. You can fetch birth chart data, daily horoscopes, and AI predictions in Server Components without exposing credentials to the browser. Vedika API works seamlessly with Next.js 15 App Router. The fetch calls in Server Components automatically deduplicate and cache, reducing redundant API calls.

How do I cache daily horoscopes in Next.js?

Use Incremental Static Regeneration (ISR) with export const revalidate = 86400 in your page file. Next.js will serve the cached horoscope page instantly and regenerate it in the background once per day. Combined with generateStaticParams for all 12 signs, this reduces API costs from thousands of calls to exactly 12 per day. At Vedika API's $0.02/call rate, that is $0.24/day for all signs.

How much does it cost to build an astrology app with Vedika API?

Vedika API starts at $12/month (Starter plan). With ISR caching, 12 daily zodiac predictions cost $0.24/day ($7.20/month). AI chat queries and birth charts are deducted from your plan's wallet credits. Vercel has a generous free tier for hosting. A typical astrology app with moderate traffic runs under $20/month total. No free tier or trial period is available since every API call incurs cost.

Can I stream AI astrology responses in Next.js?

Yes. Create a Route Handler that forwards the Server-Sent Events stream from Vedika API's /api/vedika/chat endpoint. Return new Response(response.body) with Content-Type: text/event-stream headers. On the client, read the stream with response.body.getReader() and parse the data: lines. This provides a word-by-word typing effect that users expect from modern AI interfaces.

Is Next.js or React better for astrology apps?

Next.js is better for astrology apps. SEO is critical for horoscope content (high search volume), and Next.js provides SSR and ISR out of the box. Server Components keep API keys secure without a separate backend. generateStaticParams pre-renders all 12 zodiac sign pages at build time. The metadata API generates dynamic title tags and descriptions. Plain React requires a separate Node.js server, a routing library, and manual SSR configuration for equivalent functionality.

How do I generate static pages for all 12 zodiac signs in Next.js?

Export a generateStaticParams function from your dynamic route file (app/horoscope/[sign]/page.tsx) that returns an array of the 12 zodiac signs: ['aries', 'taurus', 'gemini', ...]. Next.js pre-renders each at build time. Combined with export const revalidate = 86400, pages regenerate daily with fresh horoscope data. Each page gets unique SEO metadata via generateMetadata. Include all 12 sign URLs in your sitemap.ts with changeFrequency: 'daily' for optimal crawling.

Start Building Your Next.js Astrology App

Get your Vedika API key and start building today. The full source code from this tutorial works with Next.js 15, deploys to Vercel in minutes, and scales from a side project to a production platform. Swiss Ephemeris accuracy, AI-powered chat, 30 languages, and 120+ calculations ready to use.

Related Articles

Try the #1 Vedic Astrology API

120+ endpoints, 30 languages, Swiss Ephemeris precision. Free sandbox included -- no credit card required.

Get Free API Key