When moving your site from one domain name to another you may need to configure redirects from an old domain to a new one. While trivial if you run the site on your hosting task may become not so easy if you move a site running on some solution like Shopify or Squarespace.
The simplest solution you may consider is to create a small virtual machine on some cloud like AWS, Azure, or Google Cloud. Install nginx or haproxy there, write redirect rules, and point your old domain name to this server. While this solution is easy to implement, one downside is that you will need to pay for the server to run 24/7 even if the traffic on your website might be relatively low.
So when I was asked to help configure redirects from an old site to a new one I decided to use serverless services to implement this task. In addition to low cost and maintenance, this approach gave me the required flexibility as I need not only to return 301 redirects for a root page but also had to customize some URL paths as the structure of the new site was different from the old one.
For my solution, I decided to go with an AWS Lambda function in TypeScript. As the number of redirects was relatively low and for sure they won’t change in the future I decided to put them directly into the function and not to use any external storage like Amazon DynamoDB.
As I need to use a particular domain name with my solution I had to use Amazon API Gateway for that as you can’t assign a domain name to the Lambda function URL without using Amazon Cloudfront and in my case, I did not need CDN. I used HTTP API as I only required basic functionality.
The domain was already managed by Amazon Route53 so I only needed to switch the resolver to the new API Gateway endpoint.
To manage the resources I used AWS SAM.
Let’s look at how to implement this solution.
I like to follow the TDD approach, so I started writing unit and integration tests for my function.
My unit test looked like this
import { APIGatewayProxyEventV2, APIGatewayProxyResultV2, APIGatewayProxyStructuredResultV2 } from 'aws-lambda';
import { lambdaHandler } from '../../app';
import { expect, describe, it } from '@jest/globals';
const event: APIGatewayProxyEventV2 = {
version: '',
routeKey: '',
rawPath: '',
rawQueryString: '',
cookies: [],
headers: {},
queryStringParameters: {},
requestContext: {
accountId: '',
apiId: '',
domainName: '',
domainPrefix: '',
http: {
method: '',
path: '',
protocol: '',
sourceIp: '',
userAgent: '',
},
requestId: '',
routeKey: '',
stage: '',
time: '',
timeEpoch: 0,
},
body: '',
pathParameters: {},
isBase64Encoded: false,
stageVariables: {},
};
const urlPathMap: { [key: string]: string } = {
'/portfolio': '<https://www.newsite.com/styling-and-photo>',
'/shop': '<https://www.newsite.com/edits/my-home-edit>',
'/feedback': '<https://www.newsite.com/styling-and-photo>',
'/about': '<https://www.newsite.com/about>',
'/contact': '<https://www.newsite.com/about>',
'/listsandthoughts/layered-soft-warm-livable-habitable-minimalism-habitable-minimalism-so-what-is-it-exactly':
'<https://www.newsite.com/journal/habitable-minimalism>',
'/listsandthoughts/3-interior-design-interior-styling-books-i-can-recommend':
'<https://www.newsite.com/journal/3-interior-design-interior-styling-books>',
'/listsandthoughts/5-interior-design-amp-styling-blogs-check-every-week':
'<https://www.newsite.com/journal/5-interior-design-amp-lifestyle-blogs-i-check-regularly>',
'/listsandthoughts/5-big-ikea-products-i-have':
'<https://www.newsite.com/journal/4-ikea-products-i-have-at-home-and-really-love>',
};
describe('Unit test for app handler', function () {
it('verifies correct redirect for root path with get method', async () => {
event.rawPath = '/';
event.requestContext.http.method = 'GET';
const result: APIGatewayProxyResultV2 = (await lambdaHandler(event)) as APIGatewayProxyStructuredResultV2;
expect(result.statusCode).toEqual(301);
expect(result.headers?.Location).toEqual('<https://www.welldesignedegg.com/>');
});
it('verifies correct for post method', async () => {
event.rawPath = '/';
event.requestContext.http.method = 'POST';
const result: APIGatewayProxyResultV2 = (await lambdaHandler(event)) as APIGatewayProxyStructuredResultV2;
expect(result.statusCode).toEqual(301);
expect(result.headers?.Location).toEqual('<https://www.welldesignedegg.com/>');
});
it('verifies correct redirect for worng path and get method', async () => {
event.rawPath = '/qwerty';
event.requestContext.http.method = 'GET';
const result: APIGatewayProxyResultV2 = (await lambdaHandler(event)) as APIGatewayProxyStructuredResultV2;
expect(result.statusCode).toEqual(301);
expect(result.headers?.Location).toEqual('<https://www.welldesignedegg.com/>');
});
it('verifies all redirecds from urlPathMap', async () => {
for (const [key, value] of Object.entries(urlPathMap)) {
event.rawPath = key;
event.requestContext.http.method = 'GET';
const result: APIGatewayProxyResultV2 = (await lambdaHandler(event)) as APIGatewayProxyStructuredResultV2;
expect(result.statusCode).toEqual(301);
expect(result.headers?.Location).toEqual(value);
}
});
});
As you see in this unit test I had to redirect the root path and also to implement some path rewrites for particular paths.
I also implemented a simple integration test to run against the actual endpoint once it is live.
import request, { Response } from 'supertest';
import { expect, describe, it, beforeAll } from '@jest/globals';
describe('Integration tests for redirect app', function () {
let baseUrl: string;
beforeAll(() => {
baseUrl = process.env.BASE_URL || '<https://www.oldsite.eu>';
});
it('verifies correct redirect for root path with get method', async () => {
const result = await request(baseUrl).get('/');
expect(result.status).toEqual(301);
expect(result.headers.location).toEqual('<https://www.newsite.com/>');
});
it('verifies correct redirect for root path with post method', async () => {
const result = await request(baseUrl).post('/');
expect(result.status).toEqual(301);
expect(result.headers.location).toEqual('<https://www.newsite.com/>');
});
it('verifies correct redirect for worng path and get method', async () => {
const result = await request(baseUrl).get('/qwerty');
expect(result.status).toEqual(301);
expect(result.headers.location).toEqual('<https://www.newsite.com/>');
});
it('verifies correct redirect for /portfolio path and get method', async () => {
const result = await request(baseUrl).get('/portfolio');
expect(result.status).toEqual(301);
expect(result.headers.location).toEqual('<https://www.newsite.com/styling-and-photo>');
});
it('verifies correct redirect for /shop path and get method', async () => {
const result = await request(baseUrl).get('/shop');
expect(result.status).toEqual(301);
expect(result.headers.location).toEqual('<https://www.newsite.com/edits/my-home-edit>');
});
it('verifies correct redirect for /feedback path and get method', async () => {
const result = await request(baseUrl).get('/feedback');
expect(result.status).toEqual(301);
expect(result.headers.location).toEqual('<https://www.newsite.com/styling-and-photo>');
});
it('verifies correct redirect for /about path and get method', async () => {
const result = await request(baseUrl).get('/about');
expect(result.status).toEqual(301);
expect(result.headers.location).toEqual('<https://www.newsite.com/about>');
});
it('verifies correct redirect for /contact path and get method', async () => {
const result = await request(baseUrl).get('/contact');
expect(result.status).toEqual(301);
expect(result.headers.location).toEqual('<https://www.newsite.com/about>');
});
it('verifies correct redirect for /listsandthoughts/layered-soft-warm-livable-habitable-minimalism-habitable-minimalism-so-what-is-it-exactly path and get method', async () => {
const result = await request(baseUrl).get(
'/listsandthoughts/layered-soft-warm-livable-habitable-minimalism-habitable-minimalism-so-what-is-it-exactly',
);
expect(result.status).toEqual(301);
expect(result.headers.location).toEqual('<https://www.newsite.com/journal/habitable-minimalism>');
});
it('verifies correct redirect for /listsandthoughts/3-interior-design-interior-styling-books-i-can-recommend path and get method', async () => {
const result = await request(baseUrl).get(
'/listsandthoughts/3-interior-design-interior-styling-books-i-can-recommend',
);
expect(result.status).toEqual(301);
expect(result.headers.location).toEqual(
'<https://www.newsite.com/journal/3-interior-design-interior-styling-books>',
);
});
it('verifies correct redirect for /listsandthoughts/5-interior-design-amp-styling-blogs-check-every-week path and get method', async () => {
const result = await request(baseUrl).get(
'/listsandthoughts/5-interior-design-amp-styling-blogs-check-every-week',
);
expect(result.status).toEqual(301);
expect(result.headers.location).toEqual(
'<https://www.newsite.com/journal/5-interior-design-amp-lifestyle-blogs-i-check-regularly>',
);
});
it('verifies correct redirect for /listsandthoughts/5-big-ikea-products-i-have path and get method', async () => {
const result = await request(baseUrl).get('/listsandthoughts/5-big-ikea-products-i-have');
expect(result.status).toEqual(301);
expect(result.headers.location).toEqual(
'<https://www.newsite.com/journal/4-ikea-products-i-have-at-home-and-really-love>',
);
});
});
Now I am ready to implement the actual function code
import { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from 'aws-lambda';
const urlPathMap: { [key: string]: string } = {
'/portfolio': '<https://www.nesite.com/styling-and-photo>',
'/shop': '<https://www.nesite.com/edits/my-home-edit>',
'/feedback': '<https://www.nesite.com/styling-and-photo>',
'/about': '<https://www.nesite.com/about>',
'/contact': '<https://www.nesite.com/about>',
'/listsandthoughts/layered-soft-warm-livable-habitable-minimalism-habitable-minimalism-so-what-is-it-exactly':
'<https://www.nesite.com/journal/habitable-minimalism>',
'/listsandthoughts/3-interior-design-interior-styling-books-i-can-recommend':
'<https://www.nesite.com/journal/3-interior-design-interior-styling-books>',
'/listsandthoughts/5-interior-design-amp-styling-blogs-check-every-week':
'<https://www.nesite.com/journal/5-interior-design-amp-lifestyle-blogs-i-check-regularly>',
'/listsandthoughts/5-big-ikea-products-i-have':
'<https://www.nesite.com/journal/4-ikea-products-i-have-at-home-and-really-love>',
};
export const lambdaHandler = async (event: APIGatewayProxyEventV2): Promise<APIGatewayProxyResultV2> => {
console.log(event);
if (event.requestContext.http.method === 'GET' && event.rawPath in urlPathMap) {
return {
statusCode: 301,
isBase64Encoded: false,
headers: {
Location: urlPathMap[event.rawPath],
},
};
}
return {
statusCode: 301,
isBase64Encoded: false,
headers: {
Location: '<https://www.nesite.com/>',
},
};
};
As you see the logic is pretty simple if I receive a GET request to a path in my map I will return a redirect to a new path on a new site, all other requests I redirect to the root of the new site.
As I mentioned I used a SAM template to deploy my solution
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: >
sample-redirect
Sample SAM Template for sample-redirect
Globals:
Function:
Timeout: 10
Resources:
RedirectHttpApi:
Type: AWS::Serverless::HttpApi
Properties:
Domain:
DomainName: www.oldsite.eu
CertificateArn: #Your ACM certificate for the domain
EndpointConfiguration: REGIONAL
Route53:
HostedZoneId: #Your hosted zone id for the domain name
AccessLogSettings:
DestinationArn: !GetAtt AccessLogs.Arn
Format: $context.requestId
AccessLogs:
Type: AWS::Logs::LogGroup
RedirectFunction:
Type: AWS::Serverless::Function
Properties:
CodeUri: redirect/
Handler: app.lambdaHandler
Runtime: nodejs18.x
Architectures:
- x86_64
Events:
Redirect:
Type: HttpApi
Properties:
ApiId: !Ref RedirectHttpApi
Path: /{proxy+}
Method: any
Metadata:
BuildMethod: esbuild
BuildProperties:
Minify: true
Target: "es2020"
Sourcemap: true
EntryPoints:
- app.ts
Outputs:
RedirectHttpApi:
Description: "API Gateway endpoint URL for Prod stage for Redirect function"
Value:
Fn::Sub: '<https://$>{RedirectHttpApi}.execute-api.${AWS::Region}.${AWS::URLSuffix}/'
RedirectFunction:
Description: "Redirect Lambda Function ARN"
Value: !GetAtt RedirectFunction.Arn
RedirectFunctionIamRole:
Description: "Implicit IAM Role created for Redirect function"
Value: !GetAtt RedirectFunctionRole.Arn
You may find the full sample in this GitHub repository - https://github.com/roman-boiko/sample-redirect
Conclusion
With a Serverless stack, I could create the required functionality quickly and easily. Of course, this was quite a simple project and I can see a couple of improvements to implement in the future like not hard-coding the rewrite map into the function code but using a DynamoDB table or introducing a custom metric to track the number of redirects for each path. But for now, the application solves its purpose well and it costs me nothing to run as the traffic is pretty low.