DEV Community

Gustavo Ramirez
Gustavo Ramirez

Posted on

SvelteKit en AWS del desarrollo al deployment con SST v3

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
Enter fullscreen mode Exit fullscreen mode

Los puntos fuertes:

  • SST v3 es TypeScript sobre CDK, más declarativo.
  • El modo sst dev corre 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
Enter fullscreen mode Exit fullscreen mode

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,
    };
  },
});
Enter fullscreen mode Exit fullscreen mode

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' },
  });
};
Enter fullscreen mode Exit fullscreen mode

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' },
  });
};
Enter fullscreen mode Exit fullscreen mode

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,
    },
  };
};
Enter fullscreen mode Exit fullscreen mode
<!-- 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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

$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;
};
Enter fullscreen mode Exit fullscreen mode

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 };
  },
};
Enter fullscreen mode Exit fullscreen mode
<!-- 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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 };
};
Enter fullscreen mode Exit fullscreen mode
  • 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)