Nuxt 3 con Nitro tiene un truco que Next.js no: el mismo código se compila a diferentes runtimes. Un preset para Lambda, otro para Cloudflare Workers, otro para Node standalone. Para equipos en AWS significa que puedes desplegar Nuxt en Lambda con zero configuración adicional. Este artículo cubre el setup completo con Terraform (porque no todos usan CDK), incluyendo SSR, API routes, nitro middleware y los pain points específicos de Nuxt en serverless.
Por qué Nuxt + Nitro funciona tan bien
flowchart LR
Nuxt[Nuxt 3 app] --> Nitro[Nitro build]
Nitro --> Preset{Preset selection}
Preset -->|aws-lambda| Lambda[Lambda handler]
Preset -->|node-server| Node[Node.js app]
Preset -->|cloudflare| CF[Cloudflare Worker]
Preset -->|vercel| Vercel[Vercel edge]
Preset -->|netlify| NL[Netlify function]
Lambda --> ZIP[Optimized ZIP<br/>minified + tree-shaken]
style Nitro fill:#00DC82,color:#000
style ZIP fill:#ff9900,color:#000
El build para Lambda genera un ZIP de ~15MB (vs ~50MB de Next.js standalone). Menos cold start, menos bandwidth de deploy.
Configurando Nuxt para Lambda
// nuxt.config.ts
export default defineNuxtConfig({
compatibilityDate: '2024-11-01',
devtools: { enabled: true },
ssr: true,
nitro: {
preset: 'aws-lambda',
awsLambda: {
streaming: true,
},
prerender: {
crawlLinks: true,
routes: ['/', '/sitemap.xml', '/robots.txt'],
},
routeRules: {
'/': { prerender: true },
'/blog/**': { isr: 3600 },
'/api/**': { cors: true },
'/admin/**': { ssr: false }, // SPA para admin
},
},
modules: [
'@nuxtjs/tailwindcss',
'@pinia/nuxt',
'@nuxtjs/i18n',
'@vueuse/nuxt',
],
runtimeConfig: {
// Solo server
dynamoTable: process.env.DYNAMO_TABLE,
cognitoClientId: process.env.COGNITO_CLIENT_ID,
// Expuesto al cliente
public: {
apiBase: process.env.API_BASE_URL,
siteUrl: process.env.SITE_URL || 'http://localhost:3000',
},
},
app: {
head: {
htmlAttrs: { lang: 'es' },
meta: [
{ charset: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
],
},
},
});
El preset: 'aws-lambda' y streaming: true son toda la config que necesitas. Nitro genera el handler apropiado.
Estructura del proyecto
server/
api/
posts.get.ts # GET /api/posts
posts/[id].get.ts # GET /api/posts/:id
posts.post.ts # POST /api/posts
middleware/
auth.ts
rate-limit.ts
routes/
sitemap.xml.ts
utils/
db.ts
cognito.ts
pages/
index.vue
blog/[slug].vue
admin/**/*.vue
components/
ProductCard.vue
ShopLayout.vue
composables/
useAuth.ts
useProducts.ts
API routes tipadas
Una API route en Nuxt:
// server/api/posts.get.ts
import { z } from 'zod';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, QueryCommand } from '@aws-sdk/lib-dynamodb';
const QuerySchema = z.object({
limit: z.coerce.number().min(1).max(100).default(20),
cursor: z.string().optional(),
tag: z.string().optional(),
});
const dynamo = DynamoDBDocumentClient.from(new DynamoDBClient({}));
export default defineEventHandler(async (event) => {
const query = await getValidatedQuery(event, QuerySchema.parse);
const config = useRuntimeConfig();
const result = await dynamo.send(
new QueryCommand({
TableName: config.dynamoTable,
IndexName: 'published-index',
KeyConditionExpression: 'published = :p',
FilterExpression: query.tag ? 'contains(tags, :tag)' : undefined,
ExpressionAttributeValues: {
':p': 1,
...(query.tag && { ':tag': query.tag }),
},
Limit: query.limit,
ExclusiveStartKey: query.cursor
? JSON.parse(Buffer.from(query.cursor, 'base64').toString())
: undefined,
ScanIndexForward: false,
})
);
return {
items: result.Items ?? [],
nextCursor: result.LastEvaluatedKey
? Buffer.from(JSON.stringify(result.LastEvaluatedKey)).toString('base64')
: null,
};
});
Middleware de auth
// server/middleware/auth.ts
import { createRemoteJWKSet, jwtVerify } from 'jose';
const JWKS = createRemoteJWKSet(
new URL(`https://cognito-idp.${process.env.AWS_REGION}.amazonaws.com/${process.env.COGNITO_USER_POOL_ID}/.well-known/jwks.json`)
);
const PROTECTED_PATHS = ['/api/admin', '/api/posts'];
export default defineEventHandler(async (event) => {
const url = getRequestURL(event);
const needsAuth = PROTECTED_PATHS.some(p => url.pathname.startsWith(p));
if (!needsAuth) return;
// POST, PUT, DELETE requieren auth. GET opcional.
if (event.method === 'GET' && !url.pathname.startsWith('/api/admin')) {
return;
}
const authHeader = getHeader(event, 'authorization');
if (!authHeader?.startsWith('Bearer ')) {
throw createError({
statusCode: 401,
statusMessage: 'Unauthorized',
});
}
const token = authHeader.slice(7);
try {
const { payload } = await jwtVerify(token, JWKS, {
issuer: `https://cognito-idp.${process.env.AWS_REGION}.amazonaws.com/${process.env.COGNITO_USER_POOL_ID}`,
audience: process.env.COGNITO_CLIENT_ID,
});
event.context.user = {
id: payload.sub as string,
email: payload.email as string,
groups: (payload['cognito:groups'] as string[]) ?? [],
};
} catch {
throw createError({
statusCode: 401,
statusMessage: 'Invalid token',
});
}
});
Composables tipados
// composables/usePosts.ts
interface Post {
id: string;
title: string;
slug: string;
content: string;
publishedAt: string;
tags: string[];
}
interface PostsResponse {
items: Post[];
nextCursor: string | null;
}
export function usePosts(options: { tag?: Ref<string>; limit?: number } = {}) {
const { data, pending, error, refresh } = useFetch<PostsResponse>('/api/posts', {
query: computed(() => ({
tag: options.tag?.value,
limit: options.limit ?? 20,
})),
watch: [options.tag],
default: () => ({ items: [], nextCursor: null }),
});
return {
posts: computed(() => data.value?.items ?? []),
hasMore: computed(() => !!data.value?.nextCursor),
isLoading: pending,
error,
refresh,
};
}
Páginas Vue con SSR
<!-- pages/blog/[slug].vue -->
<script setup lang="ts">
const route = useRoute();
const slug = computed(() => route.params.slug as string);
const { data: post, error } = await useFetch(`/api/posts/${slug.value}`, {
key: `post-${slug.value}`,
});
if (error.value || !post.value) {
throw createError({
statusCode: 404,
statusMessage: 'Post not found',
fatal: true,
});
}
useSeoMeta({
title: post.value.title,
description: post.value.excerpt,
ogTitle: post.value.title,
ogDescription: post.value.excerpt,
ogImage: post.value.coverImage,
twitterCard: 'summary_large_image',
});
</script>
<template>
<article>
<header>
<h1>{{ post.title }}</h1>
<time :datetime="post.publishedAt">
{{ new Date(post.publishedAt).toLocaleDateString('es-CO') }}
</time>
</header>
<div v-html="post.content" />
<LazyCommentsSection :post-id="post.id" />
</article>
</template>
Terraform para el deploy
Para equipos que no usan CDK, Terraform hace el mismo trabajo:
# infra/main.tf
terraform {
required_version = ">= 1.5"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
backend "s3" {
bucket = "terraform-state-miempresa"
key = "nuxt-app/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-locks"
}
}
provider "aws" {
region = var.region
}
variable "region" {
default = "us-east-1"
}
variable "project" {
default = "nuxt-app"
}
variable "environment" {
default = "production"
}
# S3 bucket para assets estáticos
resource "aws_s3_bucket" "static" {
bucket = "${var.project}-${var.environment}-static"
tags = {
Project = var.project
Environment = var.environment
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "static" {
bucket = aws_s3_bucket.static.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
resource "aws_s3_bucket_public_access_block" "static" {
bucket = aws_s3_bucket.static.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
# Lambda function para SSR
data "archive_file" "lambda_zip" {
type = "zip"
source_dir = "${path.module}/../.output/server"
output_path = "${path.module}/lambda.zip"
}
resource "aws_iam_role" "lambda_role" {
name = "${var.project}-${var.environment}-lambda"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "lambda.amazonaws.com"
}
}]
})
}
resource "aws_iam_role_policy_attachment" "lambda_basic" {
role = aws_iam_role.lambda_role.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
resource "aws_iam_role_policy" "lambda_dynamo" {
role = aws_iam_role.lambda_role.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Action = [
"dynamodb:Query",
"dynamodb:GetItem",
"dynamodb:PutItem",
"dynamodb:UpdateItem",
]
Effect = "Allow"
Resource = aws_dynamodb_table.posts.arn
}, {
Action = ["dynamodb:Query"]
Effect = "Allow"
Resource = "${aws_dynamodb_table.posts.arn}/index/*"
}]
})
}
resource "aws_lambda_function" "ssr" {
function_name = "${var.project}-${var.environment}-ssr"
filename = data.archive_file.lambda_zip.output_path
source_code_hash = data.archive_file.lambda_zip.output_base64sha256
handler = "index.handler"
runtime = "nodejs20.x"
memory_size = 1024
timeout = 30
architectures = ["arm64"]
role = aws_iam_role.lambda_role.arn
environment {
variables = {
NODE_ENV = "production"
DYNAMO_TABLE = aws_dynamodb_table.posts.name
COGNITO_USER_POOL_ID = aws_cognito_user_pool.main.id
COGNITO_CLIENT_ID = aws_cognito_user_pool_client.main.id
AWS_REGION_NAME = var.region
}
}
}
resource "aws_lambda_function_url" "ssr" {
function_name = aws_lambda_function.ssr.function_name
authorization_type = "NONE"
invoke_mode = "RESPONSE_STREAM"
cors {
allow_credentials = true
allow_origins = ["*"]
allow_methods = ["*"]
allow_headers = ["*"]
max_age = 86400
}
}
# CloudFront
resource "aws_cloudfront_origin_access_control" "s3" {
name = "${var.project}-${var.environment}-oac"
origin_access_control_origin_type = "s3"
signing_behavior = "always"
signing_protocol = "sigv4"
}
resource "aws_cloudfront_distribution" "main" {
enabled = true
default_root_object = "index.html"
price_class = "PriceClass_100"
http_version = "http2and3"
origin {
domain_name = aws_s3_bucket.static.bucket_regional_domain_name
origin_id = "s3"
origin_access_control_id = aws_cloudfront_origin_access_control.s3.id
}
origin {
domain_name = replace(aws_lambda_function_url.ssr.function_url, "/^https?://([^/]+).*/", "$1")
origin_id = "lambda"
custom_origin_config {
http_port = 80
https_port = 443
origin_protocol_policy = "https-only"
origin_ssl_protocols = ["TLSv1.2"]
}
}
default_cache_behavior {
target_origin_id = "lambda"
viewer_protocol_policy = "redirect-to-https"
allowed_methods = ["GET", "HEAD", "OPTIONS", "PUT", "POST", "PATCH", "DELETE"]
cached_methods = ["GET", "HEAD"]
compress = true
cache_policy_id = aws_cloudfront_cache_policy.ssr.id
origin_request_policy_id = data.aws_cloudfront_origin_request_policy.all_viewer_except_host.id
}
ordered_cache_behavior {
path_pattern = "/_nuxt/*"
target_origin_id = "s3"
viewer_protocol_policy = "redirect-to-https"
allowed_methods = ["GET", "HEAD"]
cached_methods = ["GET", "HEAD"]
compress = true
cache_policy_id = data.aws_cloudfront_cache_policy.caching_optimized.id
}
restrictions {
geo_restriction {
restriction_type = "none"
}
}
viewer_certificate {
cloudfront_default_certificate = true
}
}
resource "aws_cloudfront_cache_policy" "ssr" {
name = "${var.project}-ssr"
default_ttl = 0
min_ttl = 0
max_ttl = 86400
parameters_in_cache_key_and_forwarded_to_origin {
enable_accept_encoding_gzip = true
enable_accept_encoding_brotli = true
cookies_config {
cookie_behavior = "none"
}
headers_config {
header_behavior = "whitelist"
headers {
items = ["Accept-Language", "CloudFront-Viewer-Country"]
}
}
query_strings_config {
query_string_behavior = "all"
}
}
}
data "aws_cloudfront_cache_policy" "caching_optimized" {
name = "Managed-CachingOptimized"
}
data "aws_cloudfront_origin_request_policy" "all_viewer_except_host" {
name = "Managed-AllViewerExceptHostHeader"
}
# Output
output "cloudfront_url" {
value = aws_cloudfront_distribution.main.domain_name
}
DynamoDB table
resource "aws_dynamodb_table" "posts" {
name = "${var.project}-posts"
billing_mode = "PAY_PER_REQUEST"
hash_key = "id"
attribute {
name = "id"
type = "S"
}
attribute {
name = "slug"
type = "S"
}
attribute {
name = "published"
type = "N"
}
attribute {
name = "publishedAt"
type = "S"
}
global_secondary_index {
name = "by-slug"
hash_key = "slug"
projection_type = "ALL"
}
global_secondary_index {
name = "published-index"
hash_key = "published"
range_key = "publishedAt"
projection_type = "ALL"
}
point_in_time_recovery {
enabled = true
}
server_side_encryption {
enabled = true
}
tags = {
Project = var.project
Environment = var.environment
}
}
Pipeline de deploy
# .github/workflows/deploy.yml
name: Deploy Nuxt
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build Nuxt
run: pnpm run build
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/GitHubDeployer
aws-region: us-east-1
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.7.0
- name: Terraform Init
run: cd infra && terraform init
- name: Terraform Plan
run: cd infra && terraform plan -out=tfplan
- name: Terraform Apply
run: cd infra && terraform apply tfplan
- name: Upload static assets
run: |
aws s3 sync .output/public s3://${{ secrets.STATIC_BUCKET }}/ \
--cache-control "public,max-age=31536000,immutable" \
--exclude "*.html"
aws s3 sync .output/public s3://${{ secrets.STATIC_BUCKET }}/ \
--cache-control "public,max-age=300" \
--exclude "*" \
--include "*.html"
- name: Invalidate CloudFront
run: |
aws cloudfront create-invalidation \
--distribution-id ${{ secrets.DISTRIBUTION_ID }} \
--paths "/*"
Métricas y observabilidad
Nuxt tiene un hook para registrar duración de rutas:
// server/plugins/monitoring.ts
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('request', (event) => {
event.context.startTime = Date.now();
});
nitroApp.hooks.hook('afterResponse', (event, { body }) => {
const duration = Date.now() - event.context.startTime;
// Enviar a CloudWatch custom metrics
console.log(JSON.stringify({
_aws: {
Timestamp: Date.now(),
CloudWatchMetrics: [{
Namespace: 'NuxtApp',
Dimensions: [['Route']],
Metrics: [{ Name: 'ResponseTime', Unit: 'Milliseconds' }],
}],
},
Route: event.path,
ResponseTime: duration,
}));
});
});
Lo que aprendí con Nuxt en Lambda
1. El build de Nitro es rapidísimo.
Comparado con Next.js standalone, Nitro toma ~30% del tiempo. Los deploys son mucho más rápidos.
2. ISR funciona bien pero no es mágico.
Los archivos cacheados se guardan en /tmp de Lambda. Cuando el container muere, se pierden. Para ISR real, guarda en S3.
3. Las API routes son productivas.
No necesitas salir de Nuxt para tener un backend. Muchos proyectos no requieren una API separada.
4. Terraform es más explícito que CDK.
Para equipos que vienen de DevOps tradicional, Terraform se siente natural. Para equipos frontend, CDK es más cercano.
5. routeRules es oro.
Mezclar páginas prerenderizadas, SSR e ISR en una sola config es un diferencial real.
Cierre
Nuxt 3 en AWS Lambda con Terraform es una combinación madura. La DX es excelente, los deploys son rápidos, y tienes control total. Para equipos Vue, es el camino natural a AWS sin pasar por Amplify.
En el próximo: SvelteKit con SST, otro stack que está ganando tracción en 2025.
Top comments (0)