Astro традиционно ассоциируется со статическими сайтами, но в режиме SSR он превращается в полноценный фреймворк для SaaS-приложений. Middleware, сессии, защищённые маршруты, API-роуты — всё это есть в Astro. Разбираем архитектуру.
Когда Astro подходит для SaaS
✅ Хорошо подходит для SaaS если:
- Большинство страниц — статичные (лендинг, документация, маркетинг)
- Дашборд — относительно простой (таблицы, формы, базовые графики)
- SSR-страницы немногочисленны (профиль, настройки, дашборд)
- Нужно сочетать маркетинговый сайт и приложение на одной платформе
⚠️ Рассмотрите Next.js или Nuxt если:
- Сложный интерактивный дашборд с десятками динамических компонентов
- Real-time данные (WebSockets, частые обновления)
- Крупная команда frontend-разработчиков, работающая с React/Vue
Hybrid режим: лучшее из SSG и SSR
// astro.config.mjs
import { defineConfig } from 'astro/config';
import cloudflare from '@astrojs/cloudflare';
export default defineConfig({
output: 'hybrid', // Большинство страниц SSG, отдельные — SSR
adapter: cloudflare(),
}); ---
// 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 бэкенд с встроенной авторизацией:
// src/lib/pocketbase.ts
import PocketBase from 'pocketbase';
export const pb = new PocketBase(import.meta.env.PB_URL); ---
// 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)
npm install lucia @lucia-auth/adapter-drizzle // 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: защита маршрутов
// 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();
}); // 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;
}
} Страница регистрации / входа
---
// 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
// 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 }));
}; // 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 }));
}; Дашборд
---
// 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
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-сервер.