SvelteKit tiene dos cosas que lo hacen particularmente interesante para AWS: el bundle cliente más pequeño de cualquier framework moderno, y un adapter oficial que lo compila a funciones Lambda sin intervención manual. Si combinas esto con SST v3 (que es básicamente CDK con azúcar sintáctico para apps serverless), obtienes una DX que compite con Vercel pero corriendo 100% en tu cuenta de AWS. Este artículo cubre el flujo completo desde npm create hasta producción.
Por qué SvelteKit + SST
flowchart LR
Dev[Desarrollador] -->|sst dev| Live[Live Lambda Dev]
Live -->|hot reload| AWS[AWS real]
Code[Código SvelteKit] -->|sst deploy| CDK[CDK stack]
CDK --> Lambda[Lambda SSR]
CDK --> S3[S3 static]
CDK --> CF[CloudFront]
style Live fill:#f97316,color:#fff
style SST fill:#ef4444,color:#fff
Los puntos fuertes:
- SST v3 es TypeScript sobre CDK, más declarativo.
- El modo
sst devcorre tu Lambda local pero contra recursos reales de AWS. - SvelteKit con adapter-aws funciona sin configuración manual.
- Bundle cliente de ~40KB para un setup básico.
Creando el proyecto
npm create svelte@latest mi-app
cd mi-app
npm install
npm install --save-dev sst@ion
npx sst init
El comando sst init detecta que es SvelteKit y te pregunta si quieres la configuración automática. Di que sí.
Configuración de SST
El archivo sst.config.ts que queda:
// sst.config.ts
/// <reference path="./.sst/platform/config.d.ts" />
export default $config({
app(input) {
return {
name: 'mi-app',
removal: input?.stage === 'production' ? 'retain' : 'remove',
home: 'aws',
providers: {
aws: {
region: 'us-east-1',
},
},
};
},
async run() {
const bucket = new sst.aws.Bucket('Uploads', {
public: false,
});
const table = new sst.aws.Dynamo('Posts', {
fields: {
id: 'string',
slug: 'string',
published: 'number',
publishedAt: 'string',
},
primaryIndex: { hashKey: 'id' },
globalIndexes: {
BySlug: { hashKey: 'slug' },
Published: { hashKey: 'published', rangeKey: 'publishedAt' },
},
});
const auth = new sst.aws.CognitoUserPool('Auth', {
usernames: ['email'],
});
const client = auth.addClient('Web');
const web = new sst.aws.SvelteKit('Web', {
link: [bucket, table, auth, client],
domain: {
name: 'miempresa.com',
redirects: ['www.miempresa.com'],
},
warm: 2,
environment: {
PUBLIC_SITE_URL: 'https://miempresa.com',
},
});
return {
web: web.url,
bucket: bucket.name,
table: table.name,
};
},
});
En ~30 líneas tienes: S3 bucket, DynamoDB con GSIs, Cognito User Pool, y SvelteKit desplegado con dominio custom.
Linking automático de recursos
Lo que hace SST especial es el sistema de linking. Cuando pones link: [bucket, table], los recursos son accesibles en tu código con tipos:
// src/routes/api/upload/+server.ts
import { Resource } from 'sst';
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import type { RequestHandler } from './$types';
const s3 = new S3Client({});
export const POST: RequestHandler = async ({ request }) => {
const formData = await request.formData();
const file = formData.get('file') as File;
if (!file) {
return new Response('No file', { status: 400 });
}
const key = `uploads/${crypto.randomUUID()}-${file.name}`;
await s3.send(
new PutObjectCommand({
Bucket: Resource.Uploads.name, // Tipado + real bucket name
Key: key,
Body: Buffer.from(await file.arrayBuffer()),
ContentType: file.type,
})
);
return new Response(JSON.stringify({ key }), {
headers: { 'Content-Type': 'application/json' },
});
};
Resource.Uploads.name es generado automáticamente por SST con el nombre real del bucket. No más variables de entorno manuales.
API route con DynamoDB
// src/routes/api/posts/+server.ts
import { Resource } from 'sst';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, QueryCommand, PutCommand } from '@aws-sdk/lib-dynamodb';
import { z } from 'zod';
import type { RequestHandler } from './$types';
const dynamo = DynamoDBDocumentClient.from(new DynamoDBClient({}));
export const GET: RequestHandler = async ({ url }) => {
const limit = Number(url.searchParams.get('limit') ?? 20);
const result = await dynamo.send(
new QueryCommand({
TableName: Resource.Posts.name,
IndexName: 'Published',
KeyConditionExpression: 'published = :p',
ExpressionAttributeValues: { ':p': 1 },
Limit: Math.min(limit, 100),
ScanIndexForward: false,
})
);
return new Response(JSON.stringify({
items: result.Items ?? [],
}), {
headers: { 'Content-Type': 'application/json' },
});
};
const CreatePostSchema = z.object({
title: z.string().min(1).max(200),
slug: z.string().min(1).regex(/^[a-z0-9-]+$/),
content: z.string().min(1),
tags: z.array(z.string()).max(10),
});
export const POST: RequestHandler = async ({ request, locals }) => {
if (!locals.user) {
return new Response('Unauthorized', { status: 401 });
}
const body = await request.json();
const validated = CreatePostSchema.safeParse(body);
if (!validated.success) {
return new Response(JSON.stringify({ errors: validated.error.issues }), {
status: 400,
});
}
const post = {
id: crypto.randomUUID(),
authorId: locals.user.id,
...validated.data,
published: 0,
createdAt: new Date().toISOString(),
};
await dynamo.send(
new PutCommand({
TableName: Resource.Posts.name,
Item: post,
})
);
return new Response(JSON.stringify(post), {
status: 201,
headers: { 'Content-Type': 'application/json' },
});
};
Páginas con load functions
SvelteKit separa load functions (SSR) de componentes. Esto es elegante porque la lógica de data fetching queda separada:
// src/routes/blog/[slug]/+page.server.ts
import { error } from '@sveltejs/kit';
import { Resource } from 'sst';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, QueryCommand } from '@aws-sdk/lib-dynamodb';
import type { PageServerLoad } from './$types';
const dynamo = DynamoDBDocumentClient.from(new DynamoDBClient({}));
export const load: PageServerLoad = async ({ params, setHeaders }) => {
const result = await dynamo.send(
new QueryCommand({
TableName: Resource.Posts.name,
IndexName: 'BySlug',
KeyConditionExpression: 'slug = :slug',
ExpressionAttributeValues: { ':slug': params.slug },
Limit: 1,
})
);
const post = result.Items?.[0];
if (!post) {
throw error(404, 'Post not found');
}
setHeaders({
'cache-control': 'public, max-age=300, stale-while-revalidate=3600',
});
return {
post,
meta: {
title: post.title,
description: post.excerpt,
},
};
};
<!-- src/routes/blog/[slug]/+page.svelte -->
<script lang="ts">
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
</script>
<svelte:head>
<title>{data.meta.title}</title>
<meta name="description" content={data.meta.description} />
</svelte:head>
<article>
<header>
<h1>{data.post.title}</h1>
<time datetime={data.post.publishedAt}>
{new Date(data.post.publishedAt).toLocaleDateString('es-CO')}
</time>
</header>
<div>{@html data.post.content}</div>
</article>
Sveltes 5 runes y reactividad
Svelte 5 cambió la sintaxis. Los componentes ahora usan "runes":
<!-- src/lib/components/Counter.svelte -->
<script lang="ts">
let count = $state(0);
let doubled = $derived(count * 2);
$effect(() => {
console.log(`Count is ${count}`);
});
function increment() {
count += 1;
}
</script>
<button onclick={increment}>
Count: {count} (doubled: {doubled})
</button>
$state crea un valor reactivo. $derived computa valores. $effect corre código cuando dependencias cambian.
Hooks para auth
SvelteKit usa hooks.server.ts para middleware:
// src/hooks.server.ts
import { Resource } from 'sst';
import { jwtVerify, createRemoteJWKSet } from 'jose';
import type { Handle } from '@sveltejs/kit';
const JWKS = createRemoteJWKSet(
new URL(
`https://cognito-idp.${process.env.AWS_REGION}.amazonaws.com/${Resource.Auth.id}/.well-known/jwks.json`
)
);
export const handle: Handle = async ({ event, resolve }) => {
const token = event.cookies.get('access_token');
if (token) {
try {
const { payload } = await jwtVerify(token, JWKS, {
issuer: `https://cognito-idp.${process.env.AWS_REGION}.amazonaws.com/${Resource.Auth.id}`,
audience: Resource.Web.id,
});
event.locals.user = {
id: payload.sub as string,
email: payload.email as string,
groups: (payload['cognito:groups'] as string[]) ?? [],
};
} catch {
event.cookies.delete('access_token', { path: '/' });
}
}
const response = await resolve(event, {
transformPageChunk: ({ html }) => {
return html.replace('%lang%', 'es');
},
filterSerializedResponseHeaders: (name) => name === 'content-type',
});
response.headers.set('x-frame-options', 'DENY');
response.headers.set('strict-transport-security', 'max-age=31536000');
return response;
};
Formularios con Actions
SvelteKit tiene un patrón llamado Actions que hace los formularios progresivos (funcionan sin JavaScript):
// src/routes/contact/+page.server.ts
import { fail } from '@sveltejs/kit';
import { z } from 'zod';
import type { Actions } from './$types';
const ContactSchema = z.object({
name: z.string().min(2).max(50),
email: z.string().email(),
message: z.string().min(10).max(1000),
});
export const actions: Actions = {
default: async ({ request }) => {
const formData = await request.formData();
const data = Object.fromEntries(formData);
const validated = ContactSchema.safeParse(data);
if (!validated.success) {
return fail(400, {
data,
errors: validated.error.flatten().fieldErrors,
});
}
// Lógica real: SES, SQS, lo que sea
await sendEmail(validated.data);
return { success: true };
},
};
<!-- src/routes/contact/+page.svelte -->
<script lang="ts">
import { enhance } from '$app/forms';
import type { ActionData } from './$types';
let { form }: { form: ActionData } = $props();
</script>
<form method="POST" use:enhance>
<label>
Nombre
<input name="name" value={form?.data?.name ?? ''} />
{#if form?.errors?.name}<span class="error">{form.errors.name[0]}</span>{/if}
</label>
<label>
Email
<input name="email" type="email" value={form?.data?.email ?? ''} />
{#if form?.errors?.email}<span class="error">{form.errors.email[0]}</span>{/if}
</label>
<label>
Mensaje
<textarea name="message">{form?.data?.message ?? ''}</textarea>
{#if form?.errors?.message}<span class="error">{form.errors.message[0]}</span>{/if}
</label>
<button type="submit">Enviar</button>
{#if form?.success}
<p class="success">Mensaje enviado</p>
{/if}
</form>
El use:enhance es lo que convierte el form progresivo: funciona sin JS pero con JS es una SPA.
Deploy y desarrollo
Los comandos que uso a diario:
# Desarrollo con Lambda live
npx sst dev
# Deploy a stage de desarrollo
npx sst deploy --stage dev
# Deploy a producción
npx sst deploy --stage production
# Remove (borrar todo)
npx sst remove --stage dev
El sst dev es mágico: corre tu código local pero los recursos (DB, S3, Cognito) son los reales de AWS. Debuggeas con breakpoints como si fuera local pero contra infra de verdad.
Bundle size real
Un hello world SvelteKit comparado con otros frameworks:
| Framework | Initial JS | First Load |
|---|---|---|
| SvelteKit | 41 KB | 68 KB |
| Solid | 58 KB | 82 KB |
| Next.js 15 | 195 KB | 280 KB |
| Nuxt 3 | 178 KB | 245 KB |
| Angular 18 | 215 KB | 312 KB |
SvelteKit gana por márgen amplio. La razón: Svelte es un compilador que genera código imperativo. No hay runtime framework que mandar al browser.
ISR con CloudFront cache
SvelteKit no tiene ISR nativo, pero con headers correctos logras el mismo efecto:
export const load: PageServerLoad = async ({ setHeaders }) => {
// ... fetch data
setHeaders({
'cache-control': 'public, max-age=60, s-maxage=3600, stale-while-revalidate=86400',
});
return { post };
};
-
max-age=60: browser cachea 1 minuto. -
s-maxage=3600: CloudFront cachea 1 hora. -
stale-while-revalidate=86400: sirve stale por 24h mientras revalida.
Esto hace que CloudFront sirva la mayoría de requests sin tocar Lambda.
Métricas reales
Proyecto SaaS mediano, métricas después de 3 meses en producción:
| Métrica | Valor |
|---|---|
| P95 TTFB (CloudFront cache hit) | 42 ms |
| P95 TTFB (Lambda) | 180 ms |
| Cold start Lambda | 380 ms |
| Cost mensual (500k MAU) | $24 |
| Time to deploy | 90 seg |
El costo es ridículo comparado con un server always-on.
Lo que aprendí
1. SST es CDK con menos fricción.
Si conoces CDK, SST es CDK con azúcar. Si no conoces CDK, SST es el punto de entrada ideal.
2. Live Lambda cambia el debugging.
Poner breakpoints en tu código y ver cómo se ejecuta con requests reales de CloudFront es una experiencia nueva.
3. Svelte 5 runes son fantásticos pero documentación es joven.
La migración de Svelte 4 a 5 es un golpe inicial. Vale la pena pero invierte tiempo en entender bien runes.
4. Resource linking elimina clase entera de bugs.
No más "puse la env var mal". Los recursos tienen types y el compilador te grita si algo no cuadra.
5. Bundle pequeño no siempre gana.
En apps con mucho contenido dinámico, la diferencia entre 40KB y 200KB se diluye. Pero en landing pages, es oro.
Cuándo no usar este stack
- Equipo grande sin experiencia en Svelte: la curva existe.
- Proyectos con ecosistema React pesado (UI libraries específicas).
- Apps que necesitan features muy específicas de Next.js (turbopack, ISR nativo).
Cierre
SvelteKit + SST es el stack más disfrutable para AWS que he probado en 2025. Bundle pequeño, DX excelente, deploy fácil, y costos bajos. Si tu equipo está dispuesto a aprender Svelte, el retorno es grande.
En el próximo artículo vamos con multi-region: cómo replicar frontend y datos entre regiones AWS con Route 53 y failover automático.
Top comments (0)