Astro calendar_today 23 апр. 2026 г. schedule 2 мин

SaaS-приложение на Astro: Архитектура и гайд 2026

Как построить SaaS-приложение на Astro с SSR: авторизация через Lucia Auth, подписки через Stripe, защищённые маршруты, PocketBase и Cloudflare Workers.

person
Журналист
Автор
SaaS-приложение на Astro SSR — архитектура и гайд

Astro традиционно ассоциируется со статическими сайтами, но в режиме SSR он превращается в полноценный фреймворк для SaaS-приложений. Middleware, сессии, защищённые маршруты, API-роуты — всё это есть в Astro. Разбираем архитектуру.

Когда Astro подходит для SaaS

Хорошо подходит для SaaS если:

  • Большинство страниц — статичные (лендинг, документация, маркетинг)
  • Дашборд — относительно простой (таблицы, формы, базовые графики)
  • SSR-страницы немногочисленны (профиль, настройки, дашборд)
  • Нужно сочетать маркетинговый сайт и приложение на одной платформе

⚠️ Рассмотрите Next.js или Nuxt если:

  • Сложный интерактивный дашборд с десятками динамических компонентов
  • Real-time данные (WebSockets, частые обновления)
  • Крупная команда frontend-разработчиков, работающая с React/Vue

Hybrid режим: лучшее из SSG и SSR

code
// astro.config.mjs
import { defineConfig } from 'astro/config';
import cloudflare from '@astrojs/cloudflare';

export default defineConfig({
  output: 'hybrid', // Большинство страниц SSG, отдельные — SSR
  adapter: cloudflare(),
});
code
---
// src/pages/index.astro — СТАТИЧНАЯ (SSG)
// Маркетинговый лендинг, пересобирается при деплое
---

// src/pages/dashboard.astro — ДИНАМИЧЕСКАЯ (SSR) export const prerender = false; // Явно
отключить пресоздание // src/pages/settings.astro — тоже SSR export const prerender =
false;

Авторизация: Lucia Auth + PocketBase

Вариант 1: PocketBase (рекомендуется для быстрого старта)

PocketBase — self-hosted бэкенд с встроенной авторизацией:

code
// src/lib/pocketbase.ts
import PocketBase from 'pocketbase';

export const pb = new PocketBase(import.meta.env.PB_URL);
code
---
// src/pages/dashboard.astro
export const prerender = false;

import { pb } from '../lib/pocketbase';

// Получить куки из запроса
const cookie = Astro.request.headers.get('cookie');
pb.authStore.loadFromCookie(cookie ?? '');

// Проверить авторизацию
if (!pb.authStore.isValid) {
  return Astro.redirect('/login');
}

const user = pb.authStore.model;
---

<h1>Добро пожаловать, {user?.name}!</h1>

Вариант 2: Lucia Auth + D1 (Cloudflare)

code
npm install lucia @lucia-auth/adapter-drizzle
code
// src/lib/auth.ts
import { Lucia } from 'lucia';
import { D1Adapter } from '@lucia-auth/adapter-drizzle';

export function initializeLucia(d1: D1Database) {
  const adapter = new D1Adapter(d1, {
    user: 'users',
    session: 'sessions',
  });

  return new Lucia(adapter, {
    sessionCookie: {
      attributes: { secure: import.meta.env.PROD },
    },
    getUserAttributes: (attributes) => ({
      email: attributes.email,
      name: attributes.name,
      plan: attributes.plan,
    }),
  });
}

Middleware: защита маршрутов

code
// src/middleware.ts
import { defineMiddleware } from 'astro:middleware';
import { pb } from './lib/pocketbase';

// Маршруты, требующие авторизации
const PROTECTED_ROUTES = ['/dashboard', '/settings', '/billing'];

export const onRequest = defineMiddleware(async (context, next) => {
  const { pathname } = context.url;

  // Загрузить сессию из cookie
  const cookie = context.request.headers.get('cookie');
  pb.authStore.loadFromCookie(cookie ?? '');

  // Сделать пользователя доступным во всех страницах
  context.locals.user = pb.authStore.isValid ? pb.authStore.model : null;
  context.locals.pb = pb;

  // Защита маршрутов
  const isProtected = PROTECTED_ROUTES.some((r) => pathname.startsWith(r));
  if (isProtected && !pb.authStore.isValid) {
    return context.redirect(`/login?redirect=${encodeURIComponent(pathname)}`);
  }

  return next();
});
code
// src/env.d.ts
/// <reference types="astro/client" />
import type PocketBase from 'pocketbase';

declare namespace App {
  interface Locals {
    user: { id: string; name: string; email: string; plan: string } | null;
    pb: PocketBase;
  }
}

Страница регистрации / входа

code
---
// src/pages/login.astro
export const prerender = false;

import { pb } from '../lib/pocketbase';

if (Astro.request.method === 'POST') {
  const data = await Astro.request.formData();
  const email = data.get('email') as string;
  const password = data.get('password') as string;

  try {
    await pb.collection('users').authWithPassword(email, password);

    // Установить куки и перенаправить
    Astro.response.headers.append('Set-Cookie', pb.authStore.exportToCookie());

    const redirect = Astro.url.searchParams.get('redirect') ?? '/dashboard';
    return Astro.redirect(redirect);
  } catch (e) {
    // Ошибка авторизации
  }
}
---

<form method="POST">
  <input name="email" type="email" required placeholder="Email" />
  <input name="password" type="password" required placeholder="Пароль" />
  <button type="submit">Войти</button>
</form>
<a href="/register">Создать аккаунт</a>

Подписки через Stripe

code
// src/pages/api/create-checkout.ts
import type { APIRoute } from 'astro';
import Stripe from 'stripe';

const stripe = new Stripe(import.meta.env.STRIPE_SECRET_KEY);

export const POST: APIRoute = async ({ request, locals }) => {
  if (!locals.user) {
    return new Response('Unauthorized', { status: 401 });
  }

  const { priceId } = await request.json();

  const session = await stripe.checkout.sessions.create({
    customer_email: locals.user.email,
    payment_method_types: ['card'],
    mode: 'subscription',
    line_items: [{ price: priceId, quantity: 1 }],
    success_url: `${import.meta.env.PUBLIC_SITE_URL}/billing/success`,
    cancel_url: `${import.meta.env.PUBLIC_SITE_URL}/billing`,
    metadata: { userId: locals.user.id },
  });

  return new Response(JSON.stringify({ url: session.url }));
};
code
// src/pages/api/stripe-webhook.ts — обновление плана после оплаты
import type { APIRoute } from 'astro';
import Stripe from 'stripe';

export const POST: APIRoute = async ({ request, locals }) => {
  const stripe = new Stripe(import.meta.env.STRIPE_SECRET_KEY);
  const sig = request.headers.get('stripe-signature')!;
  const body = await request.text();

  const event = stripe.webhooks.constructEvent(
    body,
    sig,
    import.meta.env.STRIPE_WEBHOOK_SECRET,
  );

  if (event.type === 'checkout.session.completed') {
    const session = event.data.object as Stripe.Checkout.Session;
    const userId = session.metadata?.userId;

    // Обновить план пользователя в PocketBase
    await locals.pb.collection('users').update(userId!, {
      plan: 'pro',
      stripeCustomerId: session.customer,
    });
  }

  return new Response(JSON.stringify({ received: true }));
};

Дашборд

code
---
// src/pages/dashboard.astro
export const prerender = false;

const { user, pb } = Astro.locals;

// Загрузить данные пользователя
const projects = await pb
  .collection('projects')
  .getList(1, 20, { filter: `owner = "${user!.id}"`, sort: '-created' });
---

<h1>Мои проекты</h1>
<p>Тариф: <strong>{user!.plan === 'pro' ? 'Pro' : 'Free'}</strong></p>

{
  projects.items.length === 0 ? (
    <p>
      У вас пока нет проектов. <a href="/projects/new">Создать первый →</a>
    </p>
  ) : (
    <ul>
      {projects.items.map((project) => (
        <li>
          <a href={`/projects/${project.id}`}>{project.name}</a>
          <time>{new Date(project.created).toLocaleDateString('ru-RU')}</time>
        </li>
      ))}
    </ul>
  )
}

Структура SaaS на Astro

code
saas/
├── src/
│   ├── lib/
│   │   ├── pocketbase.ts  # PB клиент
│   │   └── stripe.ts      # Stripe клиент
│   ├── middleware.ts       # Auth middleware
│   ├── pages/
│   │   ├── index.astro    # SSG: лендинг
│   │   ├── pricing.astro  # SSG: тарифы
│   │   ├── login.astro    # SSR: вход
│   │   ├── register.astro # SSR: регистрация
│   │   ├── dashboard.astro# SSR: дашборд
│   │   ├── settings.astro # SSR: настройки
│   │   ├── billing.astro  # SSR: биллинг
│   │   └── api/
│   │       ├── create-checkout.ts
│   │       └── stripe-webhook.ts

Итог

Astro в hybrid-режиме — серьёзный кандидат для SaaS начального и среднего масштаба. Маркетинговый сайт и лендинг — SSG с PageSpeed 100. Дашборд и настройки — SSR с авторизацией через middleware. Всё на одной платформе, без разделения на фронтенд и бэкенд-репозитории. Для старта это значительно проще, чем поддерживать Next.js + отдельный API-сервер.

Портрет автора Дмитрий Соколов

Senior Frontend Engineer / Tech Writer

Senior Frontend Engineer с 9-летним опытом. Специализируется на Astro.js и JAMstack.

Комментарии

Загрузка комментариев...

Оставить комментарий

Комментарии проходят модерацию перед публикацией. Правила

Рекомендуем к прочтению